diff --git a/CHANGELOG.md b/CHANGELOG.md
index b18e2745..d6d929cb 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,6 +1,15 @@
CHANGELOG
=========
+7.3
+---
+
+ * Add `is_granted_for_user()` Twig function
+ * Add `field_id()` Twig form helper function
+ * Add a `Twig` constraint that validates Twig templates
+ * Make `lint:twig` collect all deprecations instead of stopping at the first one
+ * Add `name` argument to `email.image` to override the attachment file name being set as the file path
+
7.2
---
diff --git a/Command/LintCommand.php b/Command/LintCommand.php
index 54720952..cacc7e44 100644
--- a/Command/LintCommand.php
+++ b/Command/LintCommand.php
@@ -89,7 +89,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int
$this->format = $input->getOption('format') ?? (GithubActionReporter::isGithubActionEnvironment() ? 'github' : 'txt');
if (['-'] === $filenames) {
- return $this->display($input, $output, $io, [$this->validate(file_get_contents('php://stdin'), 'Standard Input')]);
+ return $this->display($input, $output, $io, [$this->validate(file_get_contents('php://stdin'), 'Standard Input', $showDeprecations)]);
}
if (!$filenames) {
@@ -107,38 +107,15 @@ protected function execute(InputInterface $input, OutputInterface $output): int
}
}
- if ($showDeprecations) {
- $prevErrorHandler = set_error_handler(static function ($level, $message, $file, $line) use (&$prevErrorHandler) {
- if (\E_USER_DEPRECATED === $level) {
- $templateLine = 0;
- if (preg_match('/ at line (\d+)[ .]/', $message, $matches)) {
- $templateLine = $matches[1];
- }
-
- throw new Error($message, $templateLine);
- }
-
- return $prevErrorHandler ? $prevErrorHandler($level, $message, $file, $line) : false;
- });
- }
-
- try {
- $filesInfo = $this->getFilesInfo($filenames);
- } finally {
- if ($showDeprecations) {
- restore_error_handler();
- }
- }
-
- return $this->display($input, $output, $io, $filesInfo);
+ return $this->display($input, $output, $io, $this->getFilesInfo($filenames, $showDeprecations));
}
- private function getFilesInfo(array $filenames): array
+ private function getFilesInfo(array $filenames, bool $showDeprecations): array
{
$filesInfo = [];
foreach ($filenames as $filename) {
foreach ($this->findFiles($filename) as $file) {
- $filesInfo[] = $this->validate(file_get_contents($file), $file);
+ $filesInfo[] = $this->validate(file_get_contents($file), $file, $showDeprecations);
}
}
@@ -156,8 +133,26 @@ protected function findFiles(string $filename): iterable
throw new RuntimeException(\sprintf('File or directory "%s" is not readable.', $filename));
}
- private function validate(string $template, string $file): array
+ private function validate(string $template, string $file, bool $collectDeprecation): array
{
+ $deprecations = [];
+ if ($collectDeprecation) {
+ $prevErrorHandler = set_error_handler(static function ($level, $message, $fileName, $line) use (&$prevErrorHandler, &$deprecations, $file) {
+ if (\E_USER_DEPRECATED === $level) {
+ $templateLine = 0;
+ if (preg_match('/ at line (\d+)[ .]/', $message, $matches)) {
+ $templateLine = $matches[1];
+ }
+
+ $deprecations[] = ['message' => $message, 'file' => $file, 'line' => $templateLine];
+
+ return true;
+ }
+
+ return $prevErrorHandler ? $prevErrorHandler($level, $message, $fileName, $line) : false;
+ });
+ }
+
$realLoader = $this->twig->getLoader();
try {
$temporaryLoader = new ArrayLoader([$file => $template]);
@@ -169,9 +164,13 @@ private function validate(string $template, string $file): array
$this->twig->setLoader($realLoader);
return ['template' => $template, 'file' => $file, 'line' => $e->getTemplateLine(), 'valid' => false, 'exception' => $e];
+ } finally {
+ if ($collectDeprecation) {
+ restore_error_handler();
+ }
}
- return ['template' => $template, 'file' => $file, 'valid' => true];
+ return ['template' => $template, 'file' => $file, 'deprecations' => $deprecations, 'valid' => true];
}
private function display(InputInterface $input, OutputInterface $output, SymfonyStyle $io, array $files): int
@@ -188,6 +187,11 @@ private function displayTxt(OutputInterface $output, SymfonyStyle $io, array $fi
{
$errors = 0;
$githubReporter = $errorAsGithubAnnotations ? new GithubActionReporter($output) : null;
+ $deprecations = array_merge(...array_column($filesInfo, 'deprecations'));
+
+ foreach ($deprecations as $deprecation) {
+ $this->renderDeprecation($io, $deprecation['line'], $deprecation['message'], $deprecation['file'], $githubReporter);
+ }
foreach ($filesInfo as $info) {
if ($info['valid'] && $output->isVerbose()) {
@@ -204,7 +208,7 @@ private function displayTxt(OutputInterface $output, SymfonyStyle $io, array $fi
$io->warning(\sprintf('%d Twig files have valid syntax and %d contain errors.', \count($filesInfo) - $errors, $errors));
}
- return min($errors, 1);
+ return !$deprecations && !$errors ? 0 : 1;
}
private function displayJson(OutputInterface $output, array $filesInfo): int
@@ -226,6 +230,19 @@ private function displayJson(OutputInterface $output, array $filesInfo): int
return min($errors, 1);
}
+ private function renderDeprecation(SymfonyStyle $output, int $line, string $message, string $file, ?GithubActionReporter $githubReporter): void
+ {
+ $githubReporter?->error($message, $file, $line <= 0 ? null : $line);
+
+ if ($file) {
+ $output->text(\sprintf(' DEPRECATION in %s (line %s)', $file, $line));
+ } else {
+ $output->text(\sprintf(' DEPRECATION (line %s)', $line));
+ }
+
+ $output->text(\sprintf(' >> %s ', $message));
+ }
+
private function renderException(SymfonyStyle $output, string $template, Error $exception, ?string $file = null, ?GithubActionReporter $githubReporter = null): void
{
$line = $exception->getTemplateLine();
diff --git a/Extension/FormExtension.php b/Extension/FormExtension.php
index ec552d7c..f1ae7068 100644
--- a/Extension/FormExtension.php
+++ b/Extension/FormExtension.php
@@ -62,6 +62,7 @@ public function getFunctions(): array
new TwigFunction('csrf_token', [FormRenderer::class, 'renderCsrfToken']),
new TwigFunction('form_parent', 'Symfony\Bridge\Twig\Extension\twig_get_form_parent'),
new TwigFunction('field_name', $this->getFieldName(...)),
+ new TwigFunction('field_id', $this->getFieldId(...)),
new TwigFunction('field_value', $this->getFieldValue(...)),
new TwigFunction('field_label', $this->getFieldLabel(...)),
new TwigFunction('field_help', $this->getFieldHelp(...)),
@@ -93,6 +94,11 @@ public function getFieldName(FormView $view): string
return $view->vars['full_name'];
}
+ public function getFieldId(FormView $view): string
+ {
+ return $view->vars['id'];
+ }
+
public function getFieldValue(FormView $view): string|array
{
return $view->vars['value'];
diff --git a/Extension/SecurityExtension.php b/Extension/SecurityExtension.php
index 863df156..e0bb2425 100644
--- a/Extension/SecurityExtension.php
+++ b/Extension/SecurityExtension.php
@@ -12,8 +12,11 @@
namespace Symfony\Bridge\Twig\Extension;
use Symfony\Component\Security\Acl\Voter\FieldVote;
+use Symfony\Component\Security\Core\Authorization\AccessDecision;
use Symfony\Component\Security\Core\Authorization\AuthorizationCheckerInterface;
+use Symfony\Component\Security\Core\Authorization\UserAuthorizationCheckerInterface;
use Symfony\Component\Security\Core\Exception\AuthenticationCredentialsNotFoundException;
+use Symfony\Component\Security\Core\User\UserInterface;
use Symfony\Component\Security\Http\Impersonate\ImpersonateUrlGenerator;
use Twig\Extension\AbstractExtension;
use Twig\TwigFunction;
@@ -31,18 +34,47 @@ public function __construct(
) {
}
- public function isGranted(mixed $role, mixed $object = null, ?string $field = null): bool
+ public function isGranted(mixed $role, mixed $object = null, ?string $field = null, ?AccessDecision $accessDecision = null): bool
{
if (null === $this->securityChecker) {
return false;
}
if (null !== $field) {
+ if (!class_exists(FieldVote::class)) {
+ throw new \LogicException('Passing a $field to the "is_granted()" function requires symfony/acl. Try running "composer require symfony/acl-bundle" if you need field-level access control.');
+ }
+
$object = new FieldVote($object, $field);
}
try {
- return $this->securityChecker->isGranted($role, $object);
+ return $this->securityChecker->isGranted($role, $object, $accessDecision);
+ } catch (AuthenticationCredentialsNotFoundException) {
+ return false;
+ }
+ }
+
+ public function isGrantedForUser(UserInterface $user, mixed $attribute, mixed $subject = null, ?string $field = null, ?AccessDecision $accessDecision = null): bool
+ {
+ if (null === $this->securityChecker) {
+ return false;
+ }
+
+ if (!$this->securityChecker instanceof UserAuthorizationCheckerInterface) {
+ throw new \LogicException(\sprintf('You cannot use "%s()" if the authorization checker doesn\'t implement "%s".%s', __METHOD__, UserAuthorizationCheckerInterface::class, interface_exists(UserAuthorizationCheckerInterface::class) ? ' Try upgrading the "symfony/security-core" package to v7.3 minimum.' : ''));
+ }
+
+ if (null !== $field) {
+ if (!class_exists(FieldVote::class)) {
+ throw new \LogicException('Passing a $field to the "is_granted_for_user()" function requires symfony/acl. Try running "composer require symfony/acl-bundle" if you need field-level access control.');
+ }
+
+ $subject = new FieldVote($subject, $field);
+ }
+
+ try {
+ return $this->securityChecker->isGrantedForUser($user, $attribute, $subject, $accessDecision);
} catch (AuthenticationCredentialsNotFoundException) {
return false;
}
@@ -86,12 +118,18 @@ public function getImpersonatePath(string $identifier): string
public function getFunctions(): array
{
- return [
+ $functions = [
new TwigFunction('is_granted', $this->isGranted(...)),
new TwigFunction('impersonation_exit_url', $this->getImpersonateExitUrl(...)),
new TwigFunction('impersonation_exit_path', $this->getImpersonateExitPath(...)),
new TwigFunction('impersonation_url', $this->getImpersonateUrl(...)),
new TwigFunction('impersonation_path', $this->getImpersonatePath(...)),
];
+
+ if ($this->securityChecker instanceof UserAuthorizationCheckerInterface) {
+ $functions[] = new TwigFunction('is_granted_for_user', $this->isGrantedForUser(...));
+ }
+
+ return $functions;
}
}
diff --git a/Mime/TemplatedEmail.php b/Mime/TemplatedEmail.php
index 2d308947..68b3913e 100644
--- a/Mime/TemplatedEmail.php
+++ b/Mime/TemplatedEmail.php
@@ -100,7 +100,7 @@ public function markAsRendered(): void
*/
public function __serialize(): array
{
- return [$this->htmlTemplate, $this->textTemplate, $this->context, parent::__serialize(), $this->locale];
+ return [$this->htmlTemplate, $this->textTemplate, $this->context, parent::__serialize(), $this->locale];
}
/**
diff --git a/Mime/WrappedTemplatedEmail.php b/Mime/WrappedTemplatedEmail.php
index a327e94b..1feedc20 100644
--- a/Mime/WrappedTemplatedEmail.php
+++ b/Mime/WrappedTemplatedEmail.php
@@ -39,14 +39,16 @@ public function toName(): string
* some Twig namespace for email images (e.g. '@email/images/logo.png').
* @param string|null $contentType The media type (i.e. MIME type) of the image file (e.g. 'image/png').
* Some email clients require this to display embedded images.
+ * @param string|null $name A custom file name that overrides the original name (filepath) of the image
*/
- public function image(string $image, ?string $contentType = null): string
+ public function image(string $image, ?string $contentType = null, ?string $name = null): string
{
$file = $this->twig->getLoader()->getSourceContext($image);
$body = $file->getPath() ? new File($file->getPath()) : $file->getCode();
- $this->message->addPart((new DataPart($body, $image, $contentType))->asInline());
+ $name = $name ?: $image;
+ $this->message->addPart((new DataPart($body, $name, $contentType))->asInline());
- return 'cid:'.$image;
+ return 'cid:'.$name;
}
/**
diff --git a/Node/TransNode.php b/Node/TransNode.php
index 4064491f..c675db56 100644
--- a/Node/TransNode.php
+++ b/Node/TransNode.php
@@ -16,7 +16,6 @@
use Twig\Node\Expression\AbstractExpression;
use Twig\Node\Expression\ArrayExpression;
use Twig\Node\Expression\ConstantExpression;
-use Twig\Node\Expression\NameExpression;
use Twig\Node\Expression\Variable\ContextVariable;
use Twig\Node\Node;
use Twig\Node\TextNode;
@@ -120,7 +119,7 @@ private function compileString(Node $body, ArrayExpression $vars, bool $ignoreSt
if ('count' === $var && $this->hasNode('count')) {
$vars->addElement($this->getNode('count'), $key);
} else {
- $varExpr = class_exists(ContextVariable::class) ? new ContextVariable($var, $body->getTemplateLine()) : new NameExpression($var, $body->getTemplateLine());
+ $varExpr = new ContextVariable($var, $body->getTemplateLine());
$varExpr->setAttribute('ignore_strict_check', $ignoreStrictCheck);
$vars->addElement($varExpr, $key);
}
diff --git a/NodeVisitor/TranslationDefaultDomainNodeVisitor.php b/NodeVisitor/TranslationDefaultDomainNodeVisitor.php
index 3b8196fa..938d6439 100644
--- a/NodeVisitor/TranslationDefaultDomainNodeVisitor.php
+++ b/NodeVisitor/TranslationDefaultDomainNodeVisitor.php
@@ -17,10 +17,8 @@
use Twig\Node\BlockNode;
use Twig\Node\EmptyNode;
use Twig\Node\Expression\ArrayExpression;
-use Twig\Node\Expression\AssignNameExpression;
use Twig\Node\Expression\ConstantExpression;
use Twig\Node\Expression\FilterExpression;
-use Twig\Node\Expression\NameExpression;
use Twig\Node\Expression\Variable\AssignContextVariable;
use Twig\Node\Expression\Variable\ContextVariable;
use Twig\Node\ModuleNode;
@@ -60,17 +58,10 @@ public function enterNode(Node $node, Environment $env): Node
$var = '__internal_trans_default_domain'.hash('xxh128', $templateName);
- if (class_exists(Nodes::class)) {
- $name = new AssignContextVariable($var, $node->getTemplateLine());
- $this->scope->set('domain', new ContextVariable($var, $node->getTemplateLine()));
+ $name = new AssignContextVariable($var, $node->getTemplateLine());
+ $this->scope->set('domain', new ContextVariable($var, $node->getTemplateLine()));
- return new SetNode(false, new Nodes([$name]), new Nodes([$node->getNode('expr')]), $node->getTemplateLine());
- }
-
- $name = new AssignNameExpression($var, $node->getTemplateLine());
- $this->scope->set('domain', new NameExpression($var, $node->getTemplateLine()));
-
- return new SetNode(false, new Node([$name]), new Node([$node->getNode('expr')]), $node->getTemplateLine());
+ return new SetNode(false, new Nodes([$name]), new Nodes([$node->getNode('expr')]), $node->getTemplateLine());
}
if (!$this->scope->has('domain')) {
diff --git a/Tests/Command/LintCommandTest.php b/Tests/Command/LintCommandTest.php
index 3b0b453d..9e4e23a8 100644
--- a/Tests/Command/LintCommandTest.php
+++ b/Tests/Command/LintCommandTest.php
@@ -94,7 +94,20 @@ public function testLintFileWithReportedDeprecation()
$ret = $tester->execute(['filename' => [$filename], '--show-deprecations' => true], ['verbosity' => OutputInterface::VERBOSITY_VERBOSE, 'decorated' => false]);
$this->assertEquals(1, $ret, 'Returns 1 in case of error');
- $this->assertMatchesRegularExpression('/ERROR in \S+ \(line 1\)/', trim($tester->getDisplay()));
+ $this->assertMatchesRegularExpression('/DEPRECATION in \S+ \(line 1\)/', trim($tester->getDisplay()));
+ $this->assertStringContainsString('Filter "deprecated_filter" is deprecated', trim($tester->getDisplay()));
+ }
+
+ public function testLintFileWithMultipleReportedDeprecation()
+ {
+ $tester = $this->createCommandTester();
+ $filename = $this->createFile("{{ foo|deprecated_filter }}\n{{ bar|deprecated_filter }}");
+
+ $ret = $tester->execute(['filename' => [$filename], '--show-deprecations' => true], ['verbosity' => OutputInterface::VERBOSITY_VERBOSE, 'decorated' => false]);
+
+ $this->assertEquals(1, $ret, 'Returns 1 in case of error');
+ $this->assertMatchesRegularExpression('/DEPRECATION in \S+ \(line 1\)/', trim($tester->getDisplay()));
+ $this->assertMatchesRegularExpression('/DEPRECATION in \S+ \(line 2\)/', trim($tester->getDisplay()));
$this->assertStringContainsString('Filter "deprecated_filter" is deprecated', trim($tester->getDisplay()));
}
@@ -160,11 +173,7 @@ private function createCommandTester(): CommandTester
private function createCommand(): Command
{
$environment = new Environment(new FilesystemLoader(\dirname(__DIR__).'/Fixtures/templates/'));
- if (class_exists(DeprecatedCallableInfo::class)) {
- $options = ['deprecation_info' => new DeprecatedCallableInfo('foo/bar', '1.1')];
- } else {
- $options = ['deprecated' => true];
- }
+ $options = ['deprecation_info' => new DeprecatedCallableInfo('foo/bar', '1.1')];
$environment->addFilter(new TwigFilter('deprecated_filter', fn ($v) => $v, $options));
$command = new LintCommand($environment);
diff --git a/Tests/Extension/AbstractLayoutTestCase.php b/Tests/Extension/AbstractLayoutTestCase.php
index 5a541d7b..2f7410d1 100644
--- a/Tests/Extension/AbstractLayoutTestCase.php
+++ b/Tests/Extension/AbstractLayoutTestCase.php
@@ -1592,7 +1592,7 @@ public function testDateErrorBubbling()
$form->get('date')->addError(new FormError('[trans]Error![/trans]'));
$view = $form->createView();
- $this->assertEmpty($this->renderErrors($view));
+ $this->assertSame('', $this->renderErrors($view));
$this->assertNotEmpty($this->renderErrors($view['date']));
}
@@ -2213,7 +2213,7 @@ public function testTimeErrorBubbling()
$form->get('time')->addError(new FormError('[trans]Error![/trans]'));
$view = $form->createView();
- $this->assertEmpty($this->renderErrors($view));
+ $this->assertSame('', $this->renderErrors($view));
$this->assertNotEmpty($this->renderErrors($view['time']));
}
diff --git a/Tests/Extension/DumpExtensionTest.php b/Tests/Extension/DumpExtensionTest.php
index 8fe455e5..01817ce5 100644
--- a/Tests/Extension/DumpExtensionTest.php
+++ b/Tests/Extension/DumpExtensionTest.php
@@ -142,6 +142,6 @@ public function testCustomDumper()
'Custom dumper should be used to dump data.'
);
- $this->assertEmpty($output, 'Dumper output should be ignored.');
+ $this->assertSame('', $output, 'Dumper output should be ignored.');
}
}
diff --git a/Tests/Extension/FormExtensionFieldHelpersTest.php b/Tests/Extension/FormExtensionFieldHelpersTest.php
index efedc871..320b8551 100644
--- a/Tests/Extension/FormExtensionFieldHelpersTest.php
+++ b/Tests/Extension/FormExtensionFieldHelpersTest.php
@@ -119,6 +119,12 @@ public function testFieldName()
$this->assertTrue($this->view->children['username']->isRendered());
}
+ public function testFieldId()
+ {
+ $this->assertSame('register_username', $this->rawExtension->getFieldId($this->view->children['username']));
+ $this->assertSame('register_choice_multiple', $this->rawExtension->getFieldId($this->view->children['choice_multiple']));
+ }
+
public function testFieldValue()
{
$this->assertSame('tgalopin', $this->rawExtension->getFieldValue($this->view->children['username']));
diff --git a/Tests/Extension/SecurityExtensionTest.php b/Tests/Extension/SecurityExtensionTest.php
new file mode 100644
index 00000000..e0ca4dcb
--- /dev/null
+++ b/Tests/Extension/SecurityExtensionTest.php
@@ -0,0 +1,143 @@
+
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Symfony\Bridge\Twig\Tests\Extension;
+
+use PHPUnit\Framework\TestCase;
+use Symfony\Bridge\PhpUnit\ClassExistsMock;
+use Symfony\Bridge\Twig\Extension\SecurityExtension;
+use Symfony\Component\Security\Acl\Voter\FieldVote;
+use Symfony\Component\Security\Core\Authorization\AccessDecision;
+use Symfony\Component\Security\Core\Authorization\AuthorizationCheckerInterface;
+use Symfony\Component\Security\Core\Authorization\UserAuthorizationCheckerInterface;
+use Symfony\Component\Security\Core\User\UserInterface;
+
+class SecurityExtensionTest extends TestCase
+{
+ public static function setUpBeforeClass(): void
+ {
+ ClassExistsMock::register(SecurityExtension::class);
+ }
+
+ protected function tearDown(): void
+ {
+ ClassExistsMock::withMockedClasses([FieldVote::class => true]);
+ }
+
+ /**
+ * @dataProvider provideObjectFieldAclCases
+ */
+ public function testIsGrantedCreatesFieldVoteObjectWhenFieldNotNull($object, $field, $expectedSubject)
+ {
+ $securityChecker = $this->createMock(AuthorizationCheckerInterface::class);
+ $securityChecker
+ ->expects($this->once())
+ ->method('isGranted')
+ ->with('ROLE', $expectedSubject)
+ ->willReturn(true);
+
+ $securityExtension = new SecurityExtension($securityChecker);
+ $this->assertTrue($securityExtension->isGranted('ROLE', $object, $field));
+ }
+
+ public function testIsGrantedThrowsWhenFieldNotNullAndFieldVoteClassDoesNotExist()
+ {
+ if (!interface_exists(UserAuthorizationCheckerInterface::class)) {
+ $this->markTestSkipped('This test requires symfony/security-core 7.3 or superior.');
+ }
+
+ $securityChecker = $this->createMock(AuthorizationCheckerInterface::class);
+
+ ClassExistsMock::withMockedClasses([FieldVote::class => false]);
+
+ $this->expectException(\LogicException::class);
+ $this->expectExceptionMessage('Passing a $field to the "is_granted()" function requires symfony/acl.');
+
+ $securityExtension = new SecurityExtension($securityChecker);
+ $securityExtension->isGranted('ROLE', 'object', 'bar');
+ }
+
+ /**
+ * @dataProvider provideObjectFieldAclCases
+ */
+ public function testIsGrantedForUserCreatesFieldVoteObjectWhenFieldNotNull($object, $field, $expectedSubject)
+ {
+ if (!interface_exists(UserAuthorizationCheckerInterface::class)) {
+ $this->markTestSkipped('This test requires symfony/security-core 7.3 or superior.');
+ }
+
+ $user = $this->createMock(UserInterface::class);
+ $securityChecker = $this->createMockAuthorizationChecker();
+
+ $securityExtension = new SecurityExtension($securityChecker);
+ $this->assertTrue($securityExtension->isGrantedForUser($user, 'ROLE', $object, $field));
+ $this->assertSame($user, $securityChecker->user);
+ $this->assertSame('ROLE', $securityChecker->attribute);
+
+ if (null === $field) {
+ $this->assertSame($object, $securityChecker->subject);
+ } else {
+ $this->assertEquals($expectedSubject, $securityChecker->subject);
+ }
+ }
+
+ public static function provideObjectFieldAclCases()
+ {
+ return [
+ [null, null, null],
+ ['object', null, 'object'],
+ ['object', false, new FieldVote('object', false)],
+ ['object', 0, new FieldVote('object', 0)],
+ ['object', '', new FieldVote('object', '')],
+ ['object', 'field', new FieldVote('object', 'field')],
+ ];
+ }
+
+ public function testIsGrantedForUserThrowsWhenFieldNotNullAndFieldVoteClassDoesNotExist()
+ {
+ if (!interface_exists(UserAuthorizationCheckerInterface::class)) {
+ $this->markTestSkipped('This test requires symfony/security-core 7.3 or superior.');
+ }
+
+ $securityChecker = $this->createMockAuthorizationChecker();
+
+ ClassExistsMock::withMockedClasses([FieldVote::class => false]);
+
+ $this->expectException(\LogicException::class);
+ $this->expectExceptionMessage('Passing a $field to the "is_granted_for_user()" function requires symfony/acl.');
+
+ $securityExtension = new SecurityExtension($securityChecker);
+ $securityExtension->isGrantedForUser($this->createMock(UserInterface::class), 'ROLE', 'object', 'bar');
+ }
+
+ private function createMockAuthorizationChecker(): AuthorizationCheckerInterface&UserAuthorizationCheckerInterface
+ {
+ return new class implements AuthorizationCheckerInterface, UserAuthorizationCheckerInterface {
+ public UserInterface $user;
+ public mixed $attribute;
+ public mixed $subject;
+
+ public function isGranted(mixed $attribute, mixed $subject = null, ?AccessDecision $accessDecision = null): bool
+ {
+ throw new \BadMethodCallException('This method should not be called.');
+ }
+
+ public function isGrantedForUser(UserInterface $user, mixed $attribute, mixed $subject = null, ?AccessDecision $accessDecision = null): bool
+ {
+ $this->user = $user;
+ $this->attribute = $attribute;
+ $this->subject = $subject;
+
+ return true;
+ }
+ };
+ }
+}
diff --git a/Tests/Fixtures/assets/images/logo1.png b/Tests/Fixtures/assets/images/logo1.png
new file mode 100644
index 00000000..519ab7c6
Binary files /dev/null and b/Tests/Fixtures/assets/images/logo1.png differ
diff --git a/Tests/Fixtures/assets/images/logo2.png b/Tests/Fixtures/assets/images/logo2.png
new file mode 120000
index 00000000..e9f523cb
--- /dev/null
+++ b/Tests/Fixtures/assets/images/logo2.png
@@ -0,0 +1 @@
+logo1.png
\ No newline at end of file
diff --git a/Tests/Fixtures/templates/email/attach.html.twig b/Tests/Fixtures/templates/email/attach.html.twig
new file mode 100644
index 00000000..e70e32fb
--- /dev/null
+++ b/Tests/Fixtures/templates/email/attach.html.twig
@@ -0,0 +1,3 @@
+
Attachments
+{{ email.attach('@assets/images/logo1.png') }}
+{{ email.attach('@assets/images/logo2.png', name='image.png') }}
diff --git a/Tests/Fixtures/templates/email/image.html.twig b/Tests/Fixtures/templates/email/image.html.twig
new file mode 100644
index 00000000..074edf4c
--- /dev/null
+++ b/Tests/Fixtures/templates/email/image.html.twig
@@ -0,0 +1,2 @@
+
+
diff --git a/Tests/Mime/WrappedTemplatedEmailTest.php b/Tests/Mime/WrappedTemplatedEmailTest.php
new file mode 100644
index 00000000..428ebc93
--- /dev/null
+++ b/Tests/Mime/WrappedTemplatedEmailTest.php
@@ -0,0 +1,102 @@
+
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Symfony\Bridge\Twig\Tests\Mime;
+
+use PHPUnit\Framework\TestCase;
+use Symfony\Bridge\Twig\Mime\BodyRenderer;
+use Symfony\Bridge\Twig\Mime\TemplatedEmail;
+use Twig\Environment;
+use Twig\Loader\FilesystemLoader;
+
+/**
+ * @author Alexander Hofbauer buildEmail('email/image.html.twig');
+ $body = $email->toString();
+ $contentId1 = $email->getAttachments()[0]->getContentId();
+ $contentId2 = $email->getAttachments()[1]->getContentId();
+
+ $part1 = str_replace("\n", "\r\n",
+ <<
+ Content-Type: image/png; name="$contentId1"
+ Content-Transfer-Encoding: base64
+ Content-Disposition: inline;
+ name="$contentId1";
+ filename="@assets/images/logo1.png"
+
+ PART
+ );
+
+ $part2 = str_replace("\n", "\r\n",
+ <<
+ Content-Type: image/png; name="$contentId2"
+ Content-Transfer-Encoding: base64
+ Content-Disposition: inline;
+ name="$contentId2"; filename=image.png
+
+ PART
+ );
+
+ self::assertStringContainsString('', $body);
+ self::assertStringContainsString($part1, $body);
+ self::assertStringContainsString($part2, $body);
+ }
+
+ public function testEmailAttach()
+ {
+ $email = $this->buildEmail('email/attach.html.twig');
+ $body = $email->toString();
+
+ $part1 = str_replace("\n", "\r\n",
+ <<from('a.hofbauer@fify.at')
+ ->htmlTemplate($template);
+
+ $loader = new FilesystemLoader(\dirname(__DIR__).'/Fixtures/templates/');
+ $loader->addPath(\dirname(__DIR__).'/Fixtures/assets', 'assets');
+
+ $environment = new Environment($loader);
+ $renderer = new BodyRenderer($environment);
+ $renderer->render($email);
+
+ return $email;
+ }
+}
diff --git a/Tests/Node/DumpNodeTest.php b/Tests/Node/DumpNodeTest.php
index 6d584c89..33297ae4 100644
--- a/Tests/Node/DumpNodeTest.php
+++ b/Tests/Node/DumpNodeTest.php
@@ -16,9 +16,7 @@
use Twig\Compiler;
use Twig\Environment;
use Twig\Loader\LoaderInterface;
-use Twig\Node\Expression\NameExpression;
use Twig\Node\Expression\Variable\ContextVariable;
-use Twig\Node\Node;
use Twig\Node\Nodes;
class DumpNodeTest extends TestCase
@@ -73,15 +71,9 @@ public function testIndented()
public function testOneVar()
{
- if (class_exists(Nodes::class)) {
- $vars = new Nodes([
- new ContextVariable('foo', 7),
- ]);
- } else {
- $vars = new Node([
- new NameExpression('foo', 7),
- ]);
- }
+ $vars = new Nodes([
+ new ContextVariable('foo', 7),
+ ]);
$node = new DumpNode('bar', $vars, 7);
@@ -103,18 +95,10 @@ public function testOneVar()
public function testMultiVars()
{
- if (class_exists(Nodes::class)) {
- $vars = new Nodes([
- new ContextVariable('foo', 7),
- new ContextVariable('bar', 7),
- ]);
- } else {
- $vars = new Node([
- new NameExpression('foo', 7),
- new NameExpression('bar', 7),
- ]);
- }
-
+ $vars = new Nodes([
+ new ContextVariable('foo', 7),
+ new ContextVariable('bar', 7),
+ ]);
$node = new DumpNode('bar', $vars, 7);
$env = new Environment($this->createMock(LoaderInterface::class));
diff --git a/Tests/Node/FormThemeTest.php b/Tests/Node/FormThemeTest.php
index f98b93da..e581ff28 100644
--- a/Tests/Node/FormThemeTest.php
+++ b/Tests/Node/FormThemeTest.php
@@ -21,9 +21,7 @@
use Twig\Loader\LoaderInterface;
use Twig\Node\Expression\ArrayExpression;
use Twig\Node\Expression\ConstantExpression;
-use Twig\Node\Expression\NameExpression;
use Twig\Node\Expression\Variable\ContextVariable;
-use Twig\Node\Node;
use Twig\Node\Nodes;
class FormThemeTest extends TestCase
@@ -32,18 +30,11 @@ class FormThemeTest extends TestCase
public function testConstructor()
{
- $form = class_exists(ContextVariable::class) ? new ContextVariable('form', 0) : new NameExpression('form', 0);
- if (class_exists(Nodes::class)) {
- $resources = new Nodes([
- new ConstantExpression('tpl1', 0),
- new ConstantExpression('tpl2', 0),
- ]);
- } else {
- $resources = new Node([
- new ConstantExpression('tpl1', 0),
- new ConstantExpression('tpl2', 0),
- ]);
- }
+ $form = new ContextVariable('form', 0);
+ $resources = new Nodes([
+ new ConstantExpression('tpl1', 0),
+ new ConstantExpression('tpl2', 0),
+ ]);
$node = new FormThemeNode($form, $resources, 0);
@@ -54,7 +45,7 @@ public function testConstructor()
public function testCompile()
{
- $form = class_exists(ContextVariable::class) ? new ContextVariable('form', 0) : new NameExpression('form', 0);
+ $form = new ContextVariable('form', 0);
$resources = new ArrayExpression([
new ConstantExpression(1, 0),
new ConstantExpression('tpl1', 0),
diff --git a/Tests/Node/SearchAndRenderBlockNodeTest.php b/Tests/Node/SearchAndRenderBlockNodeTest.php
index ab9113ac..0c0afbfa 100644
--- a/Tests/Node/SearchAndRenderBlockNodeTest.php
+++ b/Tests/Node/SearchAndRenderBlockNodeTest.php
@@ -18,12 +18,9 @@
use Twig\Extension\CoreExtension;
use Twig\Loader\LoaderInterface;
use Twig\Node\Expression\ArrayExpression;
-use Twig\Node\Expression\ConditionalExpression;
use Twig\Node\Expression\ConstantExpression;
-use Twig\Node\Expression\NameExpression;
use Twig\Node\Expression\Ternary\ConditionalTernary;
use Twig\Node\Expression\Variable\ContextVariable;
-use Twig\Node\Node;
use Twig\Node\Nodes;
use Twig\TwigFunction;
@@ -31,15 +28,9 @@ class SearchAndRenderBlockNodeTest extends TestCase
{
public function testCompileWidget()
{
- if (class_exists(Nodes::class)) {
- $arguments = new Nodes([
- new ContextVariable('form', 0),
- ]);
- } else {
- $arguments = new Node([
- new NameExpression('form', 0),
- ]);
- }
+ $arguments = new Nodes([
+ new ContextVariable('form', 0),
+ ]);
$node = new SearchAndRenderBlockNode(new TwigFunction('form_widget'), $arguments, 0);
@@ -56,23 +47,13 @@ public function testCompileWidget()
public function testCompileWidgetWithVariables()
{
- if (class_exists(Nodes::class)) {
- $arguments = new Nodes([
- new ContextVariable('form', 0),
- new ArrayExpression([
- new ConstantExpression('foo', 0),
- new ConstantExpression('bar', 0),
- ], 0),
- ]);
- } else {
- $arguments = new Node([
- new NameExpression('form', 0),
- new ArrayExpression([
- new ConstantExpression('foo', 0),
- new ConstantExpression('bar', 0),
- ], 0),
- ]);
- }
+ $arguments = new Nodes([
+ new ContextVariable('form', 0),
+ new ArrayExpression([
+ new ConstantExpression('foo', 0),
+ new ConstantExpression('bar', 0),
+ ], 0),
+ ]);
$node = new SearchAndRenderBlockNode(new TwigFunction('form_widget'), $arguments, 0);
@@ -89,17 +70,10 @@ public function testCompileWidgetWithVariables()
public function testCompileLabelWithLabel()
{
- if (class_exists(Nodes::class)) {
- $arguments = new Nodes([
- new ContextVariable('form', 0),
- new ConstantExpression('my label', 0),
- ]);
- } else {
- $arguments = new Node([
- new NameExpression('form', 0),
- new ConstantExpression('my label', 0),
- ]);
- }
+ $arguments = new Nodes([
+ new ContextVariable('form', 0),
+ new ConstantExpression('my label', 0),
+ ]);
$node = new SearchAndRenderBlockNode(new TwigFunction('form_label'), $arguments, 0);
@@ -116,17 +90,10 @@ public function testCompileLabelWithLabel()
public function testCompileLabelWithNullLabel()
{
- if (class_exists(Nodes::class)) {
- $arguments = new Nodes([
- new ContextVariable('form', 0),
- new ConstantExpression(null, 0),
- ]);
- } else {
- $arguments = new Node([
- new NameExpression('form', 0),
- new ConstantExpression(null, 0),
- ]);
- }
+ $arguments = new Nodes([
+ new ContextVariable('form', 0),
+ new ConstantExpression(null, 0),
+ ]);
$node = new SearchAndRenderBlockNode(new TwigFunction('form_label'), $arguments, 0);
@@ -145,17 +112,10 @@ public function testCompileLabelWithNullLabel()
public function testCompileLabelWithEmptyStringLabel()
{
- if (class_exists(Nodes::class)) {
- $arguments = new Nodes([
- new ContextVariable('form', 0),
- new ConstantExpression('', 0),
- ]);
- } else {
- $arguments = new Node([
- new NameExpression('form', 0),
- new ConstantExpression('', 0),
- ]);
- }
+ $arguments = new Nodes([
+ new ContextVariable('form', 0),
+ new ConstantExpression('', 0),
+ ]);
$node = new SearchAndRenderBlockNode(new TwigFunction('form_label'), $arguments, 0);
@@ -174,15 +134,9 @@ public function testCompileLabelWithEmptyStringLabel()
public function testCompileLabelWithDefaultLabel()
{
- if (class_exists(Nodes::class)) {
- $arguments = new Nodes([
- new ContextVariable('form', 0),
- ]);
- } else {
- $arguments = new Node([
- new NameExpression('form', 0),
- ]);
- }
+ $arguments = new Nodes([
+ new ContextVariable('form', 0),
+ ]);
$node = new SearchAndRenderBlockNode(new TwigFunction('form_label'), $arguments, 0);
@@ -199,25 +153,14 @@ public function testCompileLabelWithDefaultLabel()
public function testCompileLabelWithAttributes()
{
- if (class_exists(Nodes::class)) {
- $arguments = new Nodes([
- new ContextVariable('form', 0),
- new ConstantExpression(null, 0),
- new ArrayExpression([
- new ConstantExpression('foo', 0),
- new ConstantExpression('bar', 0),
- ], 0),
- ]);
- } else {
- $arguments = new Node([
- new NameExpression('form', 0),
- new ConstantExpression(null, 0),
- new ArrayExpression([
- new ConstantExpression('foo', 0),
- new ConstantExpression('bar', 0),
- ], 0),
- ]);
- }
+ $arguments = new Nodes([
+ new ContextVariable('form', 0),
+ new ConstantExpression(null, 0),
+ new ArrayExpression([
+ new ConstantExpression('foo', 0),
+ new ConstantExpression('bar', 0),
+ ], 0),
+ ]);
$node = new SearchAndRenderBlockNode(new TwigFunction('form_label'), $arguments, 0);
@@ -237,29 +180,16 @@ public function testCompileLabelWithAttributes()
public function testCompileLabelWithLabelAndAttributes()
{
- if (class_exists(Nodes::class)) {
- $arguments = new Nodes([
- new ContextVariable('form', 0),
- new ConstantExpression('value in argument', 0),
- new ArrayExpression([
- new ConstantExpression('foo', 0),
- new ConstantExpression('bar', 0),
- new ConstantExpression('label', 0),
- new ConstantExpression('value in attributes', 0),
- ], 0),
- ]);
- } else {
- $arguments = new Node([
- new NameExpression('form', 0),
- new ConstantExpression('value in argument', 0),
- new ArrayExpression([
- new ConstantExpression('foo', 0),
- new ConstantExpression('bar', 0),
- new ConstantExpression('label', 0),
- new ConstantExpression('value in attributes', 0),
- ], 0),
- ]);
- }
+ $arguments = new Nodes([
+ new ContextVariable('form', 0),
+ new ConstantExpression('value in argument', 0),
+ new ArrayExpression([
+ new ConstantExpression('foo', 0),
+ new ConstantExpression('bar', 0),
+ new ConstantExpression('label', 0),
+ new ConstantExpression('value in attributes', 0),
+ ], 0),
+ ]);
$node = new SearchAndRenderBlockNode(new TwigFunction('form_label'), $arguments, 0);
@@ -276,33 +206,17 @@ public function testCompileLabelWithLabelAndAttributes()
public function testCompileLabelWithLabelThatEvaluatesToNull()
{
- if (class_exists(ConditionalTernary::class)) {
- $conditional = new ConditionalTernary(
- // if
- new ConstantExpression(true, 0),
- // then
- new ConstantExpression(null, 0),
- // else
- new ConstantExpression(null, 0),
- 0
- );
- } else {
- $conditional = new ConditionalExpression(
- // if
- new ConstantExpression(true, 0),
- // then
- new ConstantExpression(null, 0),
- // else
- new ConstantExpression(null, 0),
- 0
- );
- }
-
- if (class_exists(Nodes::class)) {
- $arguments = new Nodes([new ContextVariable('form', 0), $conditional]);
- } else {
- $arguments = new Node([new NameExpression('form', 0), $conditional]);
- }
+ $conditional = new ConditionalTernary(
+ // if
+ new ConstantExpression(true, 0),
+ // then
+ new ConstantExpression(null, 0),
+ // else
+ new ConstantExpression(null, 0),
+ 0
+ );
+
+ $arguments = new Nodes([new ContextVariable('form', 0), $conditional]);
$node = new SearchAndRenderBlockNode(new TwigFunction('form_label'), $arguments, 0);
@@ -323,51 +237,26 @@ public function testCompileLabelWithLabelThatEvaluatesToNull()
public function testCompileLabelWithLabelThatEvaluatesToNullAndAttributes()
{
- if (class_exists(ConditionalTernary::class)) {
- $conditional = new ConditionalTernary(
- // if
- new ConstantExpression(true, 0),
- // then
- new ConstantExpression(null, 0),
- // else
- new ConstantExpression(null, 0),
- 0
- );
- } else {
- $conditional = new ConditionalExpression(
- // if
- new ConstantExpression(true, 0),
- // then
- new ConstantExpression(null, 0),
- // else
- new ConstantExpression(null, 0),
- 0
- );
- }
-
- if (class_exists(Nodes::class)) {
- $arguments = new Nodes([
- new ContextVariable('form', 0),
- $conditional,
- new ArrayExpression([
- new ConstantExpression('foo', 0),
- new ConstantExpression('bar', 0),
- new ConstantExpression('label', 0),
- new ConstantExpression('value in attributes', 0),
- ], 0),
- ]);
- } else {
- $arguments = new Node([
- new NameExpression('form', 0),
- $conditional,
- new ArrayExpression([
- new ConstantExpression('foo', 0),
- new ConstantExpression('bar', 0),
- new ConstantExpression('label', 0),
- new ConstantExpression('value in attributes', 0),
- ], 0),
- ]);
- }
+ $conditional = new ConditionalTernary(
+ // if
+ new ConstantExpression(true, 0),
+ // then
+ new ConstantExpression(null, 0),
+ // else
+ new ConstantExpression(null, 0),
+ 0
+ );
+
+ $arguments = new Nodes([
+ new ContextVariable('form', 0),
+ $conditional,
+ new ArrayExpression([
+ new ConstantExpression('foo', 0),
+ new ConstantExpression('bar', 0),
+ new ConstantExpression('label', 0),
+ new ConstantExpression('value in attributes', 0),
+ ], 0),
+ ]);
$node = new SearchAndRenderBlockNode(new TwigFunction('form_label'), $arguments, 0);
diff --git a/Tests/Node/TransNodeTest.php b/Tests/Node/TransNodeTest.php
index 24fa4d25..5a55a0c8 100644
--- a/Tests/Node/TransNodeTest.php
+++ b/Tests/Node/TransNodeTest.php
@@ -16,7 +16,6 @@
use Twig\Compiler;
use Twig\Environment;
use Twig\Loader\LoaderInterface;
-use Twig\Node\Expression\NameExpression;
use Twig\Node\Expression\Variable\ContextVariable;
use Twig\Node\TextNode;
@@ -28,7 +27,7 @@ class TransNodeTest extends TestCase
public function testCompileStrict()
{
$body = new TextNode('trans %var%', 0);
- $vars = class_exists(ContextVariable::class) ? new ContextVariable('foo', 0) : new NameExpression('foo', 0);
+ $vars = new ContextVariable('foo', 0);
$node = new TransNode($body, null, null, $vars);
$env = new Environment($this->createMock(LoaderInterface::class), ['strict_variables' => true]);
diff --git a/Tests/NodeVisitor/TranslationNodeVisitorTest.php b/Tests/NodeVisitor/TranslationNodeVisitorTest.php
index 2d52c4ea..fc48beb6 100644
--- a/Tests/NodeVisitor/TranslationNodeVisitorTest.php
+++ b/Tests/NodeVisitor/TranslationNodeVisitorTest.php
@@ -18,7 +18,6 @@
use Twig\Node\Expression\ArrayExpression;
use Twig\Node\Expression\ConstantExpression;
use Twig\Node\Expression\FilterExpression;
-use Twig\Node\Expression\NameExpression;
use Twig\Node\Expression\Variable\ContextVariable;
use Twig\Node\Node;
use Twig\Node\Nodes;
@@ -41,17 +40,10 @@ public function testMessageExtractionWithInvalidDomainNode()
{
$message = 'new key';
- if (class_exists(Nodes::class)) {
- $n = new Nodes([
- new ArrayExpression([], 0),
- new ContextVariable('variable', 0),
- ]);
- } else {
- $n = new Node([
- new ArrayExpression([], 0),
- new NameExpression('variable', 0),
- ]);
- }
+ $n = new Nodes([
+ new ArrayExpression([], 0),
+ new ContextVariable('variable', 0),
+ ]);
$node = new FilterExpression(
new ConstantExpression($message, 0),
diff --git a/Tests/NodeVisitor/TwigNodeProvider.php b/Tests/NodeVisitor/TwigNodeProvider.php
index 64ce92bc..4a0f11b3 100644
--- a/Tests/NodeVisitor/TwigNodeProvider.php
+++ b/Tests/NodeVisitor/TwigNodeProvider.php
@@ -19,7 +19,6 @@
use Twig\Node\Expression\ConstantExpression;
use Twig\Node\Expression\FilterExpression;
use Twig\Node\ModuleNode;
-use Twig\Node\Node;
use Twig\Node\Nodes;
use Twig\Source;
use Twig\TwigFilter;
@@ -28,15 +27,13 @@ class TwigNodeProvider
{
public static function getModule($content)
{
- $emptyNodeExists = class_exists(EmptyNode::class);
-
return new ModuleNode(
new BodyNode([new ConstantExpression($content, 0)]),
null,
- $emptyNodeExists ? new EmptyNode() : new ArrayExpression([], 0),
- $emptyNodeExists ? new EmptyNode() : new ArrayExpression([], 0),
- $emptyNodeExists ? new EmptyNode() : new ArrayExpression([], 0),
- $emptyNodeExists ? new EmptyNode() : null,
+ new EmptyNode(),
+ new EmptyNode(),
+ new EmptyNode(),
+ new EmptyNode(),
new Source('', '')
);
}
@@ -50,16 +47,10 @@ public static function getTransFilter($message, $domain = null, $arguments = nul
] : [];
}
- if (class_exists(Nodes::class)) {
- $args = new Nodes($arguments);
- } else {
- $args = new Node($arguments);
- }
-
return new FilterExpression(
new ConstantExpression($message, 0),
new TwigFilter('trans'),
- $args,
+ new Nodes($arguments),
0
);
}
diff --git a/Tests/TokenParser/FormThemeTokenParserTest.php b/Tests/TokenParser/FormThemeTokenParserTest.php
index 4e8209ef..0c4bcdf6 100644
--- a/Tests/TokenParser/FormThemeTokenParserTest.php
+++ b/Tests/TokenParser/FormThemeTokenParserTest.php
@@ -18,7 +18,6 @@
use Twig\Loader\LoaderInterface;
use Twig\Node\Expression\ArrayExpression;
use Twig\Node\Expression\ConstantExpression;
-use Twig\Node\Expression\NameExpression;
use Twig\Node\Expression\Variable\ContextVariable;
use Twig\Parser;
use Twig\Source;
@@ -48,7 +47,7 @@ public static function getTestsForFormTheme()
[
'{% form_theme form "tpl1" %}',
new FormThemeNode(
- class_exists(ContextVariable::class) ? new ContextVariable('form', 1) : new NameExpression('form', 1),
+ new ContextVariable('form', 1),
new ArrayExpression([
new ConstantExpression(0, 1),
new ConstantExpression('tpl1', 1),
@@ -59,7 +58,7 @@ class_exists(ContextVariable::class) ? new ContextVariable('form', 1) : new Name
[
'{% form_theme form "tpl1" "tpl2" %}',
new FormThemeNode(
- class_exists(ContextVariable::class) ? new ContextVariable('form', 1) : new NameExpression('form', 1),
+ new ContextVariable('form', 1),
new ArrayExpression([
new ConstantExpression(0, 1),
new ConstantExpression('tpl1', 1),
@@ -72,7 +71,7 @@ class_exists(ContextVariable::class) ? new ContextVariable('form', 1) : new Name
[
'{% form_theme form with "tpl1" %}',
new FormThemeNode(
- class_exists(ContextVariable::class) ? new ContextVariable('form', 1) : new NameExpression('form', 1),
+ new ContextVariable('form', 1),
new ConstantExpression('tpl1', 1),
1
),
@@ -80,7 +79,7 @@ class_exists(ContextVariable::class) ? new ContextVariable('form', 1) : new Name
[
'{% form_theme form with ["tpl1"] %}',
new FormThemeNode(
- class_exists(ContextVariable::class) ? new ContextVariable('form', 1) : new NameExpression('form', 1),
+ new ContextVariable('form', 1),
new ArrayExpression([
new ConstantExpression(0, 1),
new ConstantExpression('tpl1', 1),
@@ -91,7 +90,7 @@ class_exists(ContextVariable::class) ? new ContextVariable('form', 1) : new Name
[
'{% form_theme form with ["tpl1", "tpl2"] %}',
new FormThemeNode(
- class_exists(ContextVariable::class) ? new ContextVariable('form', 1) : new NameExpression('form', 1),
+ new ContextVariable('form', 1),
new ArrayExpression([
new ConstantExpression(0, 1),
new ConstantExpression('tpl1', 1),
@@ -104,7 +103,7 @@ class_exists(ContextVariable::class) ? new ContextVariable('form', 1) : new Name
[
'{% form_theme form with ["tpl1", "tpl2"] only %}',
new FormThemeNode(
- class_exists(ContextVariable::class) ? new ContextVariable('form', 1) : new NameExpression('form', 1),
+ new ContextVariable('form', 1),
new ArrayExpression([
new ConstantExpression(0, 1),
new ConstantExpression('tpl1', 1),
diff --git a/Tests/Validator/Constraints/TwigTest.php b/Tests/Validator/Constraints/TwigTest.php
new file mode 100644
index 00000000..cac1b316
--- /dev/null
+++ b/Tests/Validator/Constraints/TwigTest.php
@@ -0,0 +1,56 @@
+
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Symfony\Bridge\Twig\Tests\Validator\Constraints;
+
+use PHPUnit\Framework\TestCase;
+use Symfony\Bridge\Twig\Validator\Constraints\Twig;
+use Symfony\Component\Validator\Mapping\ClassMetadata;
+use Symfony\Component\Validator\Mapping\Loader\AttributeLoader;
+
+/**
+ * @author Mokhtar Tlili
+ */
+class TwigTest extends TestCase
+{
+ public function testAttributes()
+ {
+ $metadata = new ClassMetadata(TwigDummy::class);
+ $loader = new AttributeLoader();
+ self::assertTrue($loader->loadClassMetadata($metadata));
+
+ [$bConstraint] = $metadata->properties['b']->getConstraints();
+ self::assertSame('myMessage', $bConstraint->message);
+ self::assertSame(['Default', 'TwigDummy'], $bConstraint->groups);
+
+ [$cConstraint] = $metadata->properties['c']->getConstraints();
+ self::assertSame(['my_group'], $cConstraint->groups);
+ self::assertSame('some attached data', $cConstraint->payload);
+
+ [$dConstraint] = $metadata->properties['d']->getConstraints();
+ self::assertFalse($dConstraint->skipDeprecations);
+ }
+}
+
+class TwigDummy
+{
+ #[Twig]
+ private $a;
+
+ #[Twig(message: 'myMessage')]
+ private $b;
+
+ #[Twig(groups: ['my_group'], payload: 'some attached data')]
+ private $c;
+
+ #[Twig(skipDeprecations: false)]
+ private $d;
+}
diff --git a/Tests/Validator/Constraints/TwigValidatorTest.php b/Tests/Validator/Constraints/TwigValidatorTest.php
new file mode 100644
index 00000000..da5597ad
--- /dev/null
+++ b/Tests/Validator/Constraints/TwigValidatorTest.php
@@ -0,0 +1,126 @@
+
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Symfony\Bridge\Twig\Tests\Validator\Constraints;
+
+use Symfony\Bridge\Twig\Validator\Constraints\Twig;
+use Symfony\Bridge\Twig\Validator\Constraints\TwigValidator;
+use Symfony\Component\Validator\Test\ConstraintValidatorTestCase;
+use Twig\DeprecatedCallableInfo;
+use Twig\Environment;
+use Twig\Loader\ArrayLoader;
+use Twig\TwigFilter;
+
+/**
+ * @author Mokhtar Tlili
+ */
+class TwigValidatorTest extends ConstraintValidatorTestCase
+{
+ protected function createValidator(): TwigValidator
+ {
+ $environment = new Environment(new ArrayLoader());
+ $environment->addFilter(new TwigFilter('humanize_filter', fn ($v) => $v));
+ if (class_exists(DeprecatedCallableInfo::class)) {
+ $options = ['deprecation_info' => new DeprecatedCallableInfo('foo/bar', '1.1')];
+ } else {
+ $options = ['deprecated' => true];
+ }
+
+ $environment->addFilter(new TwigFilter('deprecated_filter', fn ($v) => $v, $options));
+
+ return new TwigValidator($environment);
+ }
+
+ /**
+ * @dataProvider getValidValues
+ */
+ public function testTwigIsValid($value)
+ {
+ $this->validator->validate($value, new Twig());
+
+ $this->assertNoViolation();
+ }
+
+ /**
+ * @dataProvider getInvalidValues
+ */
+ public function testInvalidValues($value, $message, $line)
+ {
+ $constraint = new Twig('myMessageTest');
+
+ $this->validator->validate($value, $constraint);
+
+ $this->buildViolation('myMessageTest')
+ ->setParameter('{{ error }}', $message)
+ ->setParameter('{{ line }}', $line)
+ ->setCode(Twig::INVALID_TWIG_ERROR)
+ ->assertRaised();
+ }
+
+ /**
+ * When deprecations are skipped by the validator, the testsuite reporter will catch them so we need to mark the test as legacy.
+ *
+ * @group legacy
+ */
+ public function testTwigWithSkipDeprecation()
+ {
+ $constraint = new Twig(skipDeprecations: true);
+
+ $this->validator->validate('{{ name|deprecated_filter }}', $constraint);
+
+ $this->assertNoViolation();
+ }
+
+ public function testTwigWithoutSkipDeprecation()
+ {
+ $constraint = new Twig(skipDeprecations: false);
+
+ $this->validator->validate('{{ name|deprecated_filter }}', $constraint);
+
+ $line = 1;
+ $error = 'Twig Filter "deprecated_filter" is deprecated in at line 1 at line 1.';
+ if (class_exists(DeprecatedCallableInfo::class)) {
+ $line = 0;
+ $error = 'Since foo/bar 1.1: Twig Filter "deprecated_filter" is deprecated.';
+ }
+ $this->buildViolation($constraint->message)
+ ->setParameter('{{ error }}', $error)
+ ->setParameter('{{ line }}', $line)
+ ->setCode(Twig::INVALID_TWIG_ERROR)
+ ->assertRaised();
+ }
+
+ public static function getValidValues()
+ {
+ return [
+ ['Hello {{ name }}'],
+ ['{% if condition %}Yes{% else %}No{% endif %}'],
+ ['{# Comment #}'],
+ ['Hello {{ "world"|upper }}'],
+ ['{% for i in 1..3 %}Item {{ i }}{% endfor %}'],
+ ['{{ name|humanize_filter }}'],
+ ];
+ }
+
+ public static function getInvalidValues()
+ {
+ return [
+ // Invalid syntax example (missing end tag)
+ ['{% if condition %}Oops', 'Unexpected end of template at line 1.', 1],
+ // Another syntax error example (unclosed variable)
+ ['Hello {{ name', 'Unexpected token "end of template" ("end of print statement" expected) at line 1.', 1],
+ // Unknown filter error
+ ['Hello {{ name|unknown_filter }}', 'Unknown "unknown_filter" filter at line 1.', 1],
+ // Invalid variable syntax
+ ['Hello {{ .name }}', 'Unexpected token "operator" of value "." at line 1.', 1],
+ ];
+ }
+}
diff --git a/TokenParser/DumpTokenParser.php b/TokenParser/DumpTokenParser.php
index 9c12dc23..457edece 100644
--- a/TokenParser/DumpTokenParser.php
+++ b/TokenParser/DumpTokenParser.php
@@ -35,13 +35,11 @@ public function parse(Token $token): Node
{
$values = null;
if (!$this->parser->getStream()->test(Token::BLOCK_END_TYPE)) {
- $values = method_exists($this->parser, 'parseExpression') ?
- $this->parseMultitargetExpression() :
- $this->parser->getExpressionParser()->parseMultitargetExpression();
+ $values = $this->parseMultitargetExpression();
}
$this->parser->getStream()->expect(Token::BLOCK_END_TYPE);
- return new DumpNode(class_exists(LocalVariable::class) ? new LocalVariable(null, $token->getLine()) : $this->parser->getVarName(), $values, $token->getLine(), $this->getTag());
+ return new DumpNode(new LocalVariable(null, $token->getLine()), $values, $token->getLine());
}
private function parseMultitargetExpression(): Node
diff --git a/TokenParser/FormThemeTokenParser.php b/TokenParser/FormThemeTokenParser.php
index 0988eae5..347634bd 100644
--- a/TokenParser/FormThemeTokenParser.php
+++ b/TokenParser/FormThemeTokenParser.php
@@ -29,16 +29,12 @@ public function parse(Token $token): Node
$lineno = $token->getLine();
$stream = $this->parser->getStream();
- $parseExpression = method_exists($this->parser, 'parseExpression')
- ? $this->parser->parseExpression(...)
- : $this->parser->getExpressionParser()->parseExpression(...);
-
- $form = $parseExpression();
+ $form = $this->parser->parseExpression();
$only = false;
if ($this->parser->getStream()->test(Token::NAME_TYPE, 'with')) {
$this->parser->getStream()->next();
- $resources = $parseExpression();
+ $resources = $this->parser->parseExpression();
if ($this->parser->getStream()->nextIf(Token::NAME_TYPE, 'only')) {
$only = true;
@@ -46,7 +42,7 @@ public function parse(Token $token): Node
} else {
$resources = new ArrayExpression([], $stream->getCurrent()->getLine());
do {
- $resources->addElement($parseExpression());
+ $resources->addElement($this->parser->parseExpression());
} while (!$stream->test(Token::BLOCK_END_TYPE));
}
diff --git a/TokenParser/StopwatchTokenParser.php b/TokenParser/StopwatchTokenParser.php
index d77cbbf4..ea0382bc 100644
--- a/TokenParser/StopwatchTokenParser.php
+++ b/TokenParser/StopwatchTokenParser.php
@@ -12,7 +12,6 @@
namespace Symfony\Bridge\Twig\TokenParser;
use Symfony\Bridge\Twig\Node\StopwatchNode;
-use Twig\Node\Expression\AssignNameExpression;
use Twig\Node\Expression\Variable\LocalVariable;
use Twig\Node\Node;
use Twig\Token;
@@ -36,9 +35,7 @@ public function parse(Token $token): Node
$stream = $this->parser->getStream();
// {% stopwatch 'bar' %}
- $name = method_exists($this->parser, 'parseExpression') ?
- $this->parser->parseExpression() :
- $this->parser->getExpressionParser()->parseExpression();
+ $name = $this->parser->parseExpression();
$stream->expect(Token::BLOCK_END_TYPE);
@@ -47,7 +44,7 @@ public function parse(Token $token): Node
$stream->expect(Token::BLOCK_END_TYPE);
if ($this->stopwatchIsAvailable) {
- return new StopwatchNode($name, $body, class_exists(LocalVariable::class) ? new LocalVariable(null, $token->getLine()) : new AssignNameExpression($this->parser->getVarName(), $token->getLine()), $lineno, $this->getTag());
+ return new StopwatchNode($name, $body, new LocalVariable(null, $token->getLine()), $lineno);
}
return $body;
diff --git a/TokenParser/TransDefaultDomainTokenParser.php b/TokenParser/TransDefaultDomainTokenParser.php
index a64a2332..b9eb5f51 100644
--- a/TokenParser/TransDefaultDomainTokenParser.php
+++ b/TokenParser/TransDefaultDomainTokenParser.php
@@ -25,13 +25,11 @@ final class TransDefaultDomainTokenParser extends AbstractTokenParser
{
public function parse(Token $token): Node
{
- $expr = method_exists($this->parser, 'parseExpression') ?
- $this->parser->parseExpression() :
- $this->parser->getExpressionParser()->parseExpression();
+ $expr = $this->parser->parseExpression();
$this->parser->getStream()->expect(Token::BLOCK_END_TYPE);
- return new TransDefaultDomainNode($expr, $token->getLine(), $this->getTag());
+ return new TransDefaultDomainNode($expr, $token->getLine());
}
public function getTag(): string
diff --git a/TokenParser/TransTokenParser.php b/TokenParser/TransTokenParser.php
index f522356b..d4353742 100644
--- a/TokenParser/TransTokenParser.php
+++ b/TokenParser/TransTokenParser.php
@@ -36,33 +36,30 @@ public function parse(Token $token): Node
$vars = new ArrayExpression([], $lineno);
$domain = null;
$locale = null;
- $parseExpression = method_exists($this->parser, 'parseExpression')
- ? $this->parser->parseExpression(...)
- : $this->parser->getExpressionParser()->parseExpression(...);
if (!$stream->test(Token::BLOCK_END_TYPE)) {
if ($stream->test('count')) {
// {% trans count 5 %}
$stream->next();
- $count = $parseExpression();
+ $count = $this->parser->parseExpression();
}
if ($stream->test('with')) {
// {% trans with vars %}
$stream->next();
- $vars = $parseExpression();
+ $vars = $this->parser->parseExpression();
}
if ($stream->test('from')) {
// {% trans from "messages" %}
$stream->next();
- $domain = $parseExpression();
+ $domain = $this->parser->parseExpression();
}
if ($stream->test('into')) {
// {% trans into "fr" %}
$stream->next();
- $locale = $parseExpression();
+ $locale = $this->parser->parseExpression();
} elseif (!$stream->test(Token::BLOCK_END_TYPE)) {
throw new SyntaxError('Unexpected token. Twig was looking for the "with", "from", or "into" keyword.', $stream->getCurrent()->getLine(), $stream->getSourceContext());
}
diff --git a/UndefinedCallableHandler.php b/UndefinedCallableHandler.php
index 5da9a148..16421eaf 100644
--- a/UndefinedCallableHandler.php
+++ b/UndefinedCallableHandler.php
@@ -61,6 +61,7 @@ class UndefinedCallableHandler
'logout_url' => 'security-http',
'logout_path' => 'security-http',
'is_granted' => 'security-core',
+ 'is_granted_for_user' => 'security-core',
'impersonation_path' => 'security-http',
'impersonation_url' => 'security-http',
'impersonation_exit_path' => 'security-http',
diff --git a/Validator/Constraints/Twig.php b/Validator/Constraints/Twig.php
new file mode 100644
index 00000000..7cf050e8
--- /dev/null
+++ b/Validator/Constraints/Twig.php
@@ -0,0 +1,38 @@
+
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Symfony\Bridge\Twig\Validator\Constraints;
+
+use Symfony\Component\Validator\Attribute\HasNamedArguments;
+use Symfony\Component\Validator\Constraint;
+
+/**
+ * @author Mokhtar Tlili
+ */
+#[\Attribute(\Attribute::TARGET_PROPERTY | \Attribute::TARGET_METHOD | \Attribute::IS_REPEATABLE)]
+class Twig extends Constraint
+{
+ public const INVALID_TWIG_ERROR = 'e7fc55d5-e586-4cc1-924e-d27ee7fcd1b5';
+
+ protected const ERROR_NAMES = [
+ self::INVALID_TWIG_ERROR => 'INVALID_TWIG_ERROR',
+ ];
+
+ #[HasNamedArguments]
+ public function __construct(
+ public string $message = 'This value is not a valid Twig template.',
+ public bool $skipDeprecations = true,
+ ?array $groups = null,
+ mixed $payload = null,
+ ) {
+ parent::__construct(null, $groups, $payload);
+ }
+}
diff --git a/Validator/Constraints/TwigValidator.php b/Validator/Constraints/TwigValidator.php
new file mode 100644
index 00000000..3064341f
--- /dev/null
+++ b/Validator/Constraints/TwigValidator.php
@@ -0,0 +1,85 @@
+
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Symfony\Bridge\Twig\Validator\Constraints;
+
+use Symfony\Component\Validator\Constraint;
+use Symfony\Component\Validator\ConstraintValidator;
+use Symfony\Component\Validator\Exception\UnexpectedTypeException;
+use Symfony\Component\Validator\Exception\UnexpectedValueException;
+use Twig\Environment;
+use Twig\Error\Error;
+use Twig\Loader\ArrayLoader;
+use Twig\Source;
+
+/**
+ * @author Mokhtar Tlili
+ */
+class TwigValidator extends ConstraintValidator
+{
+ public function __construct(private Environment $twig)
+ {
+ }
+
+ public function validate(mixed $value, Constraint $constraint): void
+ {
+ if (!$constraint instanceof Twig) {
+ throw new UnexpectedTypeException($constraint, Twig::class);
+ }
+
+ if (null === $value || '' === $value) {
+ return;
+ }
+
+ if (!\is_scalar($value) && !$value instanceof \Stringable) {
+ throw new UnexpectedValueException($value, 'string');
+ }
+
+ $value = (string) $value;
+
+ $realLoader = $this->twig->getLoader();
+ try {
+ $temporaryLoader = new ArrayLoader([$value]);
+ $this->twig->setLoader($temporaryLoader);
+
+ if (!$constraint->skipDeprecations) {
+ $prevErrorHandler = set_error_handler(static function ($level, $message, $file, $line) use (&$prevErrorHandler) {
+ if (\E_USER_DEPRECATED !== $level) {
+ return $prevErrorHandler ? $prevErrorHandler($level, $message, $file, $line) : false;
+ }
+
+ $templateLine = 0;
+ if (preg_match('/ at line (\d+)[ .]/', $message, $matches)) {
+ $templateLine = $matches[1];
+ }
+
+ throw new Error($message, $templateLine);
+ });
+ }
+
+ try {
+ $this->twig->parse($this->twig->tokenize(new Source($value, '')));
+ } finally {
+ if (!$constraint->skipDeprecations) {
+ restore_error_handler();
+ }
+ }
+ } catch (Error $e) {
+ $this->context->buildViolation($constraint->message)
+ ->setParameter('{{ error }}', $e->getMessage())
+ ->setParameter('{{ line }}', $e->getTemplateLine())
+ ->setCode(Twig::INVALID_TWIG_ERROR)
+ ->addViolation();
+ } finally {
+ $this->twig->setLoader($realLoader);
+ }
+ }
+}
diff --git a/composer.json b/composer.json
index f0ae491d..dd2e55d7 100644
--- a/composer.json
+++ b/composer.json
@@ -19,7 +19,7 @@
"php": ">=8.2",
"symfony/deprecation-contracts": "^2.5|^3",
"symfony/translation-contracts": "^2.5|^3",
- "twig/twig": "^3.12"
+ "twig/twig": "^3.21"
},
"require-dev": {
"egulias/email-validator": "^2.1.10|^3|^4",
@@ -32,7 +32,7 @@
"symfony/finder": "^6.4|^7.0",
"symfony/form": "^6.4.20|^7.2.5",
"symfony/html-sanitizer": "^6.4|^7.0",
- "symfony/http-foundation": "^6.4|^7.0",
+ "symfony/http-foundation": "^7.3",
"symfony/http-kernel": "^6.4|^7.0",
"symfony/intl": "^6.4|^7.0",
"symfony/mime": "^6.4|^7.0",
@@ -40,6 +40,7 @@
"symfony/property-info": "^6.4|^7.0",
"symfony/routing": "^6.4|^7.0",
"symfony/translation": "^6.4|^7.0",
+ "symfony/validator": "^6.4|^7.0",
"symfony/yaml": "^6.4|^7.0",
"symfony/security-acl": "^2.8|^3.0",
"symfony/security-core": "^6.4|^7.0",
@@ -51,9 +52,9 @@
"symfony/expression-language": "^6.4|^7.0",
"symfony/web-link": "^6.4|^7.0",
"symfony/workflow": "^6.4|^7.0",
- "twig/cssinliner-extra": "^2.12|^3",
- "twig/inky-extra": "^2.12|^3",
- "twig/markdown-extra": "^2.12|^3"
+ "twig/cssinliner-extra": "^3",
+ "twig/inky-extra": "^3",
+ "twig/markdown-extra": "^3"
},
"conflict": {
"phpdocumentor/reflection-docblock": "<3.2.2",