Skip to content

Commit

Permalink
feature #4235 Add types tag (drjayvee)
Browse files Browse the repository at this point in the history
This PR was squashed before being merged into the 3.x branch.

Discussion
----------

Add types tag

The is a draft implementation for #4165.

Commits
-------

67dabd8 Add types tag
  • Loading branch information
fabpot committed Aug 29, 2024
2 parents 9120e9f + 67dabd8 commit 8aedebe
Show file tree
Hide file tree
Showing 8 changed files with 271 additions and 1 deletion.
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``
=========

.. 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 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]
],
],
];
}
}

0 comments on commit 8aedebe

Please sign in to comment.