feature #4235 Add types tag (drjayvee)

This PR was squashed before being merged into the 3.x branch.

Discussion
----------

Add types tag

The is a draft implementation for #4165.

Commits
-------

67dabd81 Add types tag
This commit is contained in:
Fabien Potencier
2024-08-29 19:26:49 +02:00
8 changed files with 271 additions and 1 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
# 3.13.0 (2024-XX-XX)
* n/a
* Add the `types` tag (experimental)
# 3.12.0 (2024-08-29)
+1
View File
@@ -21,6 +21,7 @@ Tags
macro
sandbox
set
types
use
verbatim
with
+42
View File
@@ -0,0 +1,42 @@
``types``
=========
.. versionadded:: 3.13
The ``types`` tag was added in Twig 3.13. This tag is **experimental** and can change based on usage and feedback.
The ``types`` tag declares the types of template variables.
To do this, specify a :ref:`mapping <twig-expressions>` of names to their types as strings.
Here is how to declare that ``foo`` is a boolean, while ``bar`` is an integer (see note below):
.. code-block:: twig
{% types {
foo: 'bool',
bar: 'int',
} %}
You can declare variables as optional by adding the ``?`` suffix:
.. code-block:: twig
{% types {
foo: 'bool',
bar?: 'int',
} %}
By default, this tag does not affect the template compilation or runtime behavior.
Its purpose is to enable designers and developers to document and specify the context's available
and/or required variables. While Twig itself does not validate variables or their types, this tag enables extensions
to do this.
Additionally, :ref:`Twig extensions <creating_extensions>` can analyze these tags to perform compile-time and
runtime analysis of templates.
.. note::
The syntax for and contents of type strings are intentionally left out of scope.
+2
View File
@@ -82,6 +82,7 @@ use Twig\TokenParser\ImportTokenParser;
use Twig\TokenParser\IncludeTokenParser;
use Twig\TokenParser\MacroTokenParser;
use Twig\TokenParser\SetTokenParser;
use Twig\TokenParser\TypesTokenParser;
use Twig\TokenParser\UseTokenParser;
use Twig\TokenParser\WithTokenParser;
use Twig\TwigFilter;
@@ -182,6 +183,7 @@ final class CoreExtension extends AbstractExtension
new ImportTokenParser(),
new FromTokenParser(),
new SetTokenParser(),
new TypesTokenParser(),
new FlushTokenParser(),
new DoTokenParser(),
new EmbedTokenParser(),
+28
View File
@@ -0,0 +1,28 @@
<?php
namespace Twig\Node;
use Twig\Attribute\YieldReady;
use Twig\Compiler;
/**
* Represents a types node.
*
* @author Jeroen Versteeg <jeroen@alisqi.com>
*/
#[YieldReady]
class TypesNode extends Node implements NodeCaptureInterface
{
/**
* @param array<string, array{type: string, optional: bool}> $types
*/
public function __construct(array $types, int $lineno)
{
parent::__construct([], ['mapping' => $types], $lineno);
}
public function compile(Compiler $compiler)
{
// Don't compile anything.
}
}
+85
View File
@@ -0,0 +1,85 @@
<?php
/*
* This file is part of Twig.
*
* (c) Fabien Potencier
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Twig\TokenParser;
use Twig\Error\SyntaxError;
use Twig\Node\Node;
use Twig\Node\TypesNode;
use Twig\Token;
use Twig\TokenStream;
/**
* Declare variable types.
*
* {% types {foo: 'int', bar?: 'string'} %}
*
* @author Jeroen Versteeg <jeroen@alisqi.com>
* @internal
*/
final class TypesTokenParser extends AbstractTokenParser
{
public function parse(Token $token): Node
{
$stream = $this->parser->getStream();
$types = $this->parseSimpleMappingExpression($stream);
$stream->expect(Token::BLOCK_END_TYPE);
return new TypesNode($types, $token->getLine());
}
/**
* @return array<string, array{type: string, optional: bool}>
*
* @throws SyntaxError
*/
private function parseSimpleMappingExpression(TokenStream $stream): array
{
$stream->expect(Token::PUNCTUATION_TYPE, '{', 'A mapping element was expected');
$types = [];
$first = true;
while (!$stream->test(Token::PUNCTUATION_TYPE, '}')) {
if (!$first) {
$stream->expect(Token::PUNCTUATION_TYPE, ',', 'A type string must be followed by a comma');
// trailing ,?
if ($stream->test(Token::PUNCTUATION_TYPE, '}')) {
break;
}
}
$first = false;
$nameToken = $stream->expect(Token::NAME_TYPE);
$isOptional = $stream->nextIf(Token::PUNCTUATION_TYPE, '?') !== null;
$stream->expect(Token::PUNCTUATION_TYPE, ':', 'A type name must be followed by a colon (:)');
$valueToken = $stream->expect(Token::STRING_TYPE);
$types[$nameToken->getValue()] = [
'type' => $valueToken->getValue(),
'optional' => $isOptional,
];
}
$stream->expect(Token::PUNCTUATION_TYPE, '}', 'An opened mapping is not properly closed');
return $types;
}
public function getTag(): string
{
return 'types';
}
}
+43
View File
@@ -0,0 +1,43 @@
<?php
namespace Twig\Tests\Node;
use Twig\Node\TypesNode;
use Twig\Test\NodeTestCase;
class TypesTest extends NodeTestCase
{
private function getValidMapping(): array
{
// {foo: 'string', bar?: 'int'}
return [
'foo' => [
'type' => 'string',
'optional' => false,
],
'bar' => [
'type' => 'int',
'optional' => true,
]
];
}
public function testConstructor()
{
$types = $this->getValidMapping();
$node = new TypesNode($types, 1);
$this->assertEquals($types, $node->getAttribute('mapping'));
}
public function getTests()
{
return [
// 1st test: Node shouldn't compile at all
[
new TypesNode($this->getValidMapping(), 1),
''
]
];
}
}
@@ -0,0 +1,69 @@
<?php
namespace Twig\Tests\TokenParser;
use PHPUnit\Framework\TestCase;
use Twig\Environment;
use Twig\Loader\ArrayLoader;
use Twig\Parser;
use Twig\Source;
class TypesTokenParserTest extends TestCase
{
/** @dataProvider getMappingTests */
public function testMappingParsing(string $template, array $expected): void
{
$env = new Environment(new ArrayLoader(), ['cache' => false, 'autoescape' => false]);
$stream = $env->tokenize($source = new Source($template, ''));
$parser = new Parser($env);
$typesNode = $parser->parse($stream)->getNode('body')->getNode('0');
self::assertEquals($expected, $typesNode->getAttribute('mapping'));
}
public function getMappingTests(): array
{
return [
// empty mapping
[
'{% types {} %}',
[],
],
// simple
[
'{% types {foo: "bar"} %}',
[
'foo' => ['type' => 'bar', 'optional' => false]
],
],
// trailing comma
[
'{% types {foo: "bar",} %}',
[
'foo' => ['type' => 'bar', 'optional' => false]
],
],
// optional name
[
'{% types {foo?: "bar"} %}',
[
'foo' => ['type' => 'bar', 'optional' => true]
],
],
// multiple pairs, duplicate values
[
'{% types {foo: "foo", bar?: "foo", baz: "baz"} %}',
[
'foo' => ['type' => 'foo', 'optional' => false],
'bar' => ['type' => 'foo', 'optional' => true],
'baz' => ['type' => 'baz', 'optional' => false]
],
],
];
}
}