From 67dabd8182bccd557070cffce843b3f4226e231b Mon Sep 17 00:00:00 2001 From: Jeroen Versteeg Date: Fri, 23 Aug 2024 16:15:55 +0200 Subject: [PATCH] Add types tag --- CHANGELOG | 2 +- doc/tags/index.rst | 1 + doc/tags/types.rst | 42 +++++++++++ src/Extension/CoreExtension.php | 2 + src/Node/TypesNode.php | 28 +++++++ src/TokenParser/TypesTokenParser.php | 85 ++++++++++++++++++++++ tests/Node/TypesTest.php | 43 +++++++++++ tests/TokenParser/TypesTokenParserTest.php | 69 ++++++++++++++++++ 8 files changed, 271 insertions(+), 1 deletion(-) create mode 100644 doc/tags/types.rst create mode 100644 src/Node/TypesNode.php create mode 100644 src/TokenParser/TypesTokenParser.php create mode 100644 tests/Node/TypesTest.php create mode 100644 tests/TokenParser/TypesTokenParserTest.php diff --git a/CHANGELOG b/CHANGELOG index 64ea20ce8f7..86f696fc422 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -1,6 +1,6 @@ # 3.13.0 (2024-XX-XX) - * n/a + * Add the `types` tag (experimental) # 3.12.0 (2024-08-29) diff --git a/doc/tags/index.rst b/doc/tags/index.rst index b3c10408071..692ca6094d1 100644 --- a/doc/tags/index.rst +++ b/doc/tags/index.rst @@ -21,6 +21,7 @@ Tags macro sandbox set + types use verbatim with diff --git a/doc/tags/types.rst b/doc/tags/types.rst new file mode 100644 index 00000000000..389afc1fc0b --- /dev/null +++ b/doc/tags/types.rst @@ -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 ` 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 ` 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. + diff --git a/src/Extension/CoreExtension.php b/src/Extension/CoreExtension.php index fdb11dc7aa8..92c5fd5a820 100644 --- a/src/Extension/CoreExtension.php +++ b/src/Extension/CoreExtension.php @@ -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; @@ -182,6 +183,7 @@ public function getTokenParsers(): array new ImportTokenParser(), new FromTokenParser(), new SetTokenParser(), + new TypesTokenParser(), new FlushTokenParser(), new DoTokenParser(), new EmbedTokenParser(), diff --git a/src/Node/TypesNode.php b/src/Node/TypesNode.php new file mode 100644 index 00000000000..5cdece665cf --- /dev/null +++ b/src/Node/TypesNode.php @@ -0,0 +1,28 @@ + + */ +#[YieldReady] +class TypesNode extends Node implements NodeCaptureInterface +{ + /** + * @param array $types + */ + public function __construct(array $types, int $lineno) + { + parent::__construct([], ['mapping' => $types], $lineno); + } + + public function compile(Compiler $compiler) + { + // Don't compile anything. + } +} diff --git a/src/TokenParser/TypesTokenParser.php b/src/TokenParser/TypesTokenParser.php new file mode 100644 index 00000000000..b579fd0c04e --- /dev/null +++ b/src/TokenParser/TypesTokenParser.php @@ -0,0 +1,85 @@ + + * @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 + * + * @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'; + } +} diff --git a/tests/Node/TypesTest.php b/tests/Node/TypesTest.php new file mode 100644 index 00000000000..ff6c2dfac12 --- /dev/null +++ b/tests/Node/TypesTest.php @@ -0,0 +1,43 @@ + [ + '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), + '' + ] + ]; + } +} diff --git a/tests/TokenParser/TypesTokenParserTest.php b/tests/TokenParser/TypesTokenParserTest.php new file mode 100644 index 00000000000..1d27d9d151a --- /dev/null +++ b/tests/TokenParser/TypesTokenParserTest.php @@ -0,0 +1,69 @@ + 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] + ], + ], + ]; + } +}