Skip to content

Commit 215b232

Browse files
committed
Add the twig constraint and its validator
1 parent 95d1191 commit 215b232

File tree

8 files changed

+261
-0
lines changed

8 files changed

+261
-0
lines changed

src/Symfony/Bridge/Twig/CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ CHANGELOG
55
---
66

77
* Deprecate passing a tag to the constructor of `FormThemeNode`
8+
* Add the `Twig` constraint for validating Twig content
89

910
7.1
1011
---
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Symfony package.
5+
*
6+
* (c) Fabien Potencier <fabien@symfony.com>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
namespace Symfony\Bridge\Twig\Tests\Validator\Constraints;
13+
14+
use PHPUnit\Framework\TestCase;
15+
use Symfony\Bridge\Twig\Validator\Constraints\Twig;
16+
use Symfony\Component\Validator\Mapping\ClassMetadata;
17+
use Symfony\Component\Validator\Mapping\Loader\AttributeLoader;
18+
19+
/**
20+
* @author Mokhtar Tlili <tlili.mokhtar@gmail.com>
21+
*/
22+
class TwigTest extends TestCase
23+
{
24+
public function testAttributes()
25+
{
26+
$metadata = new ClassMetadata(TwigDummy::class);
27+
$loader = new AttributeLoader();
28+
self::assertTrue($loader->loadClassMetadata($metadata));
29+
30+
[$bConstraint] = $metadata->properties['b']->getConstraints();
31+
self::assertSame('myMessage', $bConstraint->message);
32+
self::assertSame(['Default', 'TwigDummy'], $bConstraint->groups);
33+
34+
[$cConstraint] = $metadata->properties['c']->getConstraints();
35+
self::assertSame(['my_group'], $cConstraint->groups);
36+
self::assertSame('some attached data', $cConstraint->payload);
37+
}
38+
}
39+
40+
class TwigDummy
41+
{
42+
#[Twig]
43+
private $a;
44+
45+
#[Twig(message: 'myMessage')]
46+
private $b;
47+
48+
#[Twig(groups: ['my_group'], payload: 'some attached data')]
49+
private $c;
50+
}
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Symfony package.
5+
*
6+
* (c) Fabien Potencier <fabien@symfony.com>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
namespace Symfony\Bridge\Twig\Tests\Validator\Constraints;
13+
14+
use Symfony\Bridge\Twig\Validator\Constraints\Twig;
15+
use Symfony\Bridge\Twig\Validator\Constraints\TwigValidator;
16+
use Symfony\Component\Validator\Test\ConstraintValidatorTestCase;
17+
use Twig\Environment;
18+
use Twig\Loader\ArrayLoader;
19+
use Twig\TwigFilter;
20+
21+
/**
22+
* @author Mokhtar Tlili <tlili.mokhtar@gmail.com>
23+
*/
24+
class TwigValidatorTest extends ConstraintValidatorTestCase
25+
{
26+
protected function createValidator(): TwigValidator
27+
{
28+
$environment = new Environment(new ArrayLoader());
29+
$environment->addFilter(new TwigFilter('humanize_filter', fn ($v) => $v));
30+
31+
return new TwigValidator($environment);
32+
}
33+
34+
/**
35+
* @dataProvider getValidValues
36+
*/
37+
public function testTwigIsValid($value)
38+
{
39+
$this->validator->validate($value, new Twig());
40+
41+
$this->assertNoViolation();
42+
}
43+
44+
/**
45+
* @dataProvider getInvalidValues
46+
*/
47+
public function testInvalidValues($value, $message, $line)
48+
{
49+
$constraint = new Twig('myMessageTest');
50+
51+
$this->validator->validate($value, $constraint);
52+
53+
$this->buildViolation('myMessageTest')
54+
->setParameter('{{ error }}', $message)
55+
->setParameter('{{ line }}', $line)
56+
->setCode(Twig::INVALID_TWIG_ERROR)
57+
->assertRaised();
58+
}
59+
60+
public static function getValidValues()
61+
{
62+
return [
63+
['Hello {{ name }}'],
64+
['{% if condition %}Yes{% else %}No{% endif %}'],
65+
['{# Comment #}'],
66+
['Hello {{ "world" | upper }}'],
67+
['{% for i in 1..3 %}Item {{ i }}{% endfor %}'],
68+
['{{ name|humanize_filter }}'],
69+
];
70+
}
71+
72+
public static function getInvalidValues()
73+
{
74+
return [
75+
// Invalid syntax example (missing end tag)
76+
['{% if condition %}Oops', 'Unexpected end of template at line 1.', 1],
77+
// Another syntax error example (unclosed variable)
78+
['Hello {{ name', 'Unexpected token "end of template" ("end of print statement" expected) at line 1.', 1],
79+
// Unknown filter error
80+
['Hello {{ name | unknown_filter }}', 'Unknown "unknown_filter" filter at line 1.', 1],
81+
// Invalid variable syntax
82+
['Hello {{ .name }}', 'Unexpected token "punctuation" of value "." at line 1.', 1],
83+
];
84+
}
85+
}
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Symfony package.
5+
*
6+
* (c) Fabien Potencier <fabien@symfony.com>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
namespace Symfony\Bridge\Twig\Validator\Constraints;
13+
14+
use Symfony\Component\Validator\Attribute\HasNamedArguments;
15+
use Symfony\Component\Validator\Constraint;
16+
17+
/**
18+
* @author Mokhtar Tlili <tlili.mokhtar@gmail.com>
19+
*/
20+
#[\Attribute(\Attribute::TARGET_PROPERTY | \Attribute::TARGET_METHOD | \Attribute::IS_REPEATABLE)]
21+
class Twig extends Constraint
22+
{
23+
public const INVALID_TWIG_ERROR = 'e7fc55d5-e586-4cc1-924e-d27ee7fcd1b5';
24+
25+
protected const ERROR_NAMES = [
26+
self::INVALID_TWIG_ERROR => 'INVALID_TWIG_ERROR',
27+
];
28+
29+
#[HasNamedArguments]
30+
public function __construct(
31+
public string $message = 'This value is not valid Twig.',
32+
?array $groups = null,
33+
mixed $payload = null,
34+
) {
35+
parent::__construct(null, $groups, $payload);
36+
}
37+
}
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Symfony package.
5+
*
6+
* (c) Fabien Potencier <fabien@symfony.com>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
namespace Symfony\Bridge\Twig\Validator\Constraints;
13+
14+
use Symfony\Component\Validator\Constraint;
15+
use Symfony\Component\Validator\ConstraintValidator;
16+
use Symfony\Component\Validator\Exception\UnexpectedTypeException;
17+
use Symfony\Component\Validator\Exception\UnexpectedValueException;
18+
use Twig\Environment;
19+
use Twig\Error\Error;
20+
use Twig\Loader\ArrayLoader;
21+
use Twig\Source;
22+
23+
/**
24+
* @author Mokhtar Tlili <tlili.mokhtar@gmail.com>
25+
*/
26+
class TwigValidator extends ConstraintValidator
27+
{
28+
public function __construct(private Environment $twig)
29+
{
30+
}
31+
32+
public function validate(mixed $value, Constraint $constraint): void
33+
{
34+
if (!$constraint instanceof Twig) {
35+
throw new UnexpectedTypeException($constraint, Twig::class);
36+
}
37+
38+
if (null === $value || '' === $value) {
39+
return;
40+
}
41+
42+
if (!\is_scalar($value) && !$value instanceof \Stringable) {
43+
throw new UnexpectedValueException($value, 'string');
44+
}
45+
46+
$value = (string) $value;
47+
48+
$prevErrorHandler = set_error_handler(static function ($level, $message, $file, $line) use (&$prevErrorHandler) {
49+
if (\E_USER_DEPRECATED === $level) {
50+
$templateLine = 0;
51+
if (preg_match('/ at line (\d+)[ .]/', $message, $matches)) {
52+
$templateLine = $matches[1];
53+
}
54+
55+
throw new Error($message, $templateLine);
56+
}
57+
58+
return $prevErrorHandler ? $prevErrorHandler($level, $message, $file, $line) : false;
59+
});
60+
61+
$realLoader = $this->twig->getLoader();
62+
try {
63+
$temporaryLoader = new ArrayLoader([$value]);
64+
$this->twig->setLoader($temporaryLoader);
65+
$this->twig->parse($this->twig->tokenize(new Source($value, '')));
66+
} catch (Error $e) {
67+
$this->context->buildViolation($constraint->message)
68+
->setParameter('{{ error }}', $e->getMessage())
69+
->setParameter('{{ line }}', $e->getTemplateLine())
70+
->setCode(Twig::INVALID_TWIG_ERROR)
71+
->addViolation();
72+
} finally {
73+
$this->twig->setLoader($realLoader);
74+
restore_error_handler();
75+
}
76+
}
77+
}

src/Symfony/Bridge/Twig/composer.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@
4040
"symfony/property-info": "^6.4|^7.0",
4141
"symfony/routing": "^6.4|^7.0",
4242
"symfony/translation": "^6.4|^7.0",
43+
"symfony/validator": "^6.4|^7.0",
4344
"symfony/yaml": "^6.4|^7.0",
4445
"symfony/security-acl": "^2.8|^3.0",
4546
"symfony/security-core": "^6.4|^7.0",

src/Symfony/Bundle/TwigBundle/CHANGELOG.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,11 @@
11
CHANGELOG
22
=========
33

4+
7.2
5+
---
6+
7+
* Added support for the new `twig` validator (from Twig Bridge)
8+
49
7.1
510
---
611

src/Symfony/Bundle/TwigBundle/Resources/config/twig.php

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@
3333
use Symfony\Bridge\Twig\Extension\WorkflowExtension;
3434
use Symfony\Bridge\Twig\Extension\YamlExtension;
3535
use Symfony\Bridge\Twig\Translation\TwigExtractor;
36+
use Symfony\Bridge\Twig\Validator\Constraints\TwigValidator;
3637
use Symfony\Bundle\TwigBundle\CacheWarmer\TemplateCacheWarmer;
3738
use Symfony\Bundle\TwigBundle\DependencyInjection\Configurator\EnvironmentConfigurator;
3839
use Symfony\Bundle\TwigBundle\TemplateIterator;
@@ -172,5 +173,9 @@
172173
->set('controller.template_attribute_listener', TemplateAttributeListener::class)
173174
->args([service('twig')])
174175
->tag('kernel.event_subscriber')
176+
177+
->set('twig.validator', TwigValidator::class)
178+
->args([service('twig')])
179+
->tag('validator.constraint_validator')
175180
;
176181
};

0 commit comments

Comments
 (0)