Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add types tag #4235

Closed
wants to merge 11 commits into from
2 changes: 1 addition & 1 deletion CHANGELOG
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# 3.13.0 (2024-XX-XX)

* n/a
* Add the `types` tag (experimental)

# 3.12.0 (2024-08-29)

Expand Down
1 change: 1 addition & 0 deletions doc/tags/index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ Tags
macro
sandbox
set
types
use
verbatim
with
42 changes: 42 additions & 0 deletions doc/tags/types.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
``types``
=========

drjayvee marked this conversation as resolved.
Show resolved Hide resolved
.. 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.
drjayvee marked this conversation as resolved.
Show resolved Hide resolved

2 changes: 2 additions & 0 deletions src/Extension/CoreExtension.php
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,7 @@
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;
Expand Down Expand Up @@ -182,6 +183,7 @@ public function getTokenParsers(): array
new ImportTokenParser(),
new FromTokenParser(),
new SetTokenParser(),
new TypesTokenParser(),
new FlushTokenParser(),
new DoTokenParser(),
new EmbedTokenParser(),
Expand Down
28 changes: 28 additions & 0 deletions src/Node/TypesNode.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
<?php

namespace Twig\Node;

use Twig\Attribute\YieldReady;
use Twig\Compiler;

/**
* Represents a types node.
*
* @author Jeroen Versteeg <[email protected]>
*/
#[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 changes: 85 additions & 0 deletions src/TokenParser/TypesTokenParser.php
Original file line number Diff line number Diff line change
@@ -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 <[email protected]>
* @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 changes: 43 additions & 0 deletions tests/Node/TypesTest.php
Original file line number Diff line number Diff line change
@@ -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),
''
]
];
}
}
69 changes: 69 additions & 0 deletions tests/TokenParser/TypesTokenParserTest.php
Original file line number Diff line number Diff line change
@@ -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]
],
],
];
}
}