diff --git a/CHANGELOG b/CHANGELOG index 0eb1aacba88..456408a40a3 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -1,6 +1,6 @@ # 3.24.1 (2026-XX-XX) - * n/a + * Add support for `docs` property for `BlockNode` and `TypesNode` # 3.24.0 (2026-03-17) diff --git a/src/Node/BlockNode.php b/src/Node/BlockNode.php index b4f939cf630..b466711f8d1 100644 --- a/src/Node/BlockNode.php +++ b/src/Node/BlockNode.php @@ -33,6 +33,7 @@ public function compile(Compiler $compiler): void $compiler ->addDebugInfo($this) ->write("/**\n") + ->write($this->hasAttribute('docs') ? ' * '.str_replace("\n", "\n * ", $this->getAttribute('docs'))."\n" : '') ->write(" * @return iterable\n") ->write(" */\n") ->write(\sprintf("public function block_%s(array \$context, array \$blocks = []): iterable\n", $this->getAttribute('name')), "{\n") diff --git a/src/Node/TypesNode.php b/src/Node/TypesNode.php index a1828808385..3aac55210b7 100644 --- a/src/Node/TypesNode.php +++ b/src/Node/TypesNode.php @@ -23,7 +23,7 @@ class TypesNode extends Node { /** - * @param array $types + * @param array $types */ public function __construct(array $types, int $lineno) { diff --git a/src/TokenParser/BlockTokenParser.php b/src/TokenParser/BlockTokenParser.php index 452b323e533..caf1f9a527a 100644 --- a/src/TokenParser/BlockTokenParser.php +++ b/src/TokenParser/BlockTokenParser.php @@ -42,6 +42,14 @@ public function parse(Token $token): Node $this->parser->pushLocalScope(); $this->parser->pushBlockStack($name); + // Check for optional docs="..." attribute + $docs = null; + if ($stream->test(Token::NAME_TYPE, 'docs')) { + $stream->next(); + $stream->expect(Token::OPERATOR_TYPE, '='); + $docs = $stream->expect(Token::STRING_TYPE)->getValue(); + } + if ($stream->nextIf(Token::BLOCK_END_TYPE)) { $body = $this->parser->subparse([$this, 'decideBlockEnd'], true); if ($token = $stream->nextIf(Token::NAME_TYPE)) { @@ -59,6 +67,9 @@ public function parse(Token $token): Node $stream->expect(Token::BLOCK_END_TYPE); $block->setNode('body', $body); + if (null !== $docs) { + $block->setAttribute('docs', $docs); + } $this->parser->popBlockStack(); $this->parser->popLocalScope(); diff --git a/src/TokenParser/TypesTokenParser.php b/src/TokenParser/TypesTokenParser.php index 2c7b77c024b..0a038ae7080 100644 --- a/src/TokenParser/TypesTokenParser.php +++ b/src/TokenParser/TypesTokenParser.php @@ -38,7 +38,7 @@ public function parse(Token $token): Node } /** - * @return array + * @return array * * @throws SyntaxError */ @@ -69,9 +69,18 @@ private function parseSimpleMappingExpression(TokenStream $stream): array $valueToken = $stream->expect(Token::STRING_TYPE); + // Check for optional docs="..." attribute + $docs = null; + if ($stream->test(Token::NAME_TYPE, 'docs')) { + $stream->next(); + $stream->expect(Token::OPERATOR_TYPE, '='); + $docs = $stream->expect(Token::STRING_TYPE)->getValue(); + } + $types[$nameToken->getValue()] = [ 'type' => $valueToken->getValue(), 'optional' => $isOptional, + 'docs' => $docs, ]; } diff --git a/tests/Node/BlockTest.php b/tests/Node/BlockTest.php index d5bf7037f40..cb3bfe7e08b 100644 --- a/tests/Node/BlockTest.php +++ b/tests/Node/BlockTest.php @@ -40,6 +40,8 @@ public function testConstructor() public static function provideTests(): iterable { $tests = []; + + // block without docs $tests[] = [new BlockNode('foo', new TextNode('foo', 1), 1), <<setAttribute('docs', 'The foo block description'); + $tests[] = [$blockWithDocs, << + */ +public function block_foo(array \$context, array \$blocks = []): iterable +{ + \$macros = \$this->macros; + yield "foo"; + yield from []; +} +EOF, new Environment(new ArrayLoader()), + ]; + + // block with multiline docs + $blockWithMultilineDocs = new BlockNode('foo', new TextNode('foo', 1), 1); + $blockWithMultilineDocs->setAttribute('docs', "First line\nSecond line"); + $tests[] = [$blockWithMultilineDocs, << + */ +public function block_foo(array \$context, array \$blocks = []): iterable +{ + \$macros = \$this->macros; + yield "foo"; + yield from []; +} +EOF, new Environment(new ArrayLoader()), + ]; + return $tests; } } diff --git a/tests/Node/TypesTest.php b/tests/Node/TypesTest.php index 0bb1ef044f1..00a8c7eba6c 100644 --- a/tests/Node/TypesTest.php +++ b/tests/Node/TypesTest.php @@ -23,10 +23,12 @@ private static function getValidMapping(): array 'foo' => [ 'type' => 'string', 'optional' => false, + 'docs' => null, ], 'bar' => [ 'type' => 'number', 'optional' => true, + 'docs' => null, ], ]; } diff --git a/tests/TokenParser/BlockTokenParserTest.php b/tests/TokenParser/BlockTokenParserTest.php new file mode 100644 index 00000000000..e9e805ca216 --- /dev/null +++ b/tests/TokenParser/BlockTokenParserTest.php @@ -0,0 +1,70 @@ + false, 'autoescape' => false]); + $stream = $env->tokenize(new Source($template, '')); + $parser = new Parser($env); + + $blockNode = $parser->parse($stream)->getNode('blocks')->getNode($blockName)->getNode('0'); + + if (null === $expectedDocs) { + self::assertFalse($blockNode->hasAttribute('docs')); + } else { + self::assertEquals($expectedDocs, $blockNode->getAttribute('docs')); + } + } + + public static function getBlockTests(): array + { + return [ + // block without docs + [ + 'template' => '{% block content %}foo{% endblock %}', + 'blockName' => 'content', + 'expectedDocs' => null, + ], + + // block with docs + [ + 'template' => '{% block content docs="The main content block" %}foo{% endblock %}', + 'blockName' => 'content', + 'expectedDocs' => 'The main content block', + ], + + // shorthand block without docs + [ + 'template' => '{% block title "Hello" %}', + 'blockName' => 'title', + 'expectedDocs' => null, + ], + + // shorthand block with docs + [ + 'template' => '{% block title docs="The page title" "Hello" %}', + 'blockName' => 'title', + 'expectedDocs' => 'The page title', + ], + ]; + } +} diff --git a/tests/TokenParser/TypesTokenParserTest.php b/tests/TokenParser/TypesTokenParserTest.php index 49e0ac051eb..9619ff568af 100644 --- a/tests/TokenParser/TypesTokenParserTest.php +++ b/tests/TokenParser/TypesTokenParserTest.php @@ -20,7 +20,7 @@ class TypesTokenParserTest extends TestCase { /** @dataProvider getMappingTests */ - public function testMappingParsing(string $template, array $expected): void + public function testMappingParsing(string $template, array $expected) { $env = new Environment(new ArrayLoader(), ['cache' => false, 'autoescape' => false]); $stream = $env->tokenize(new Source($template, '')); @@ -44,7 +44,7 @@ public static function getMappingTests(): array [ '{% types {foo: "bar"} %}', [ - 'foo' => ['type' => 'bar', 'optional' => false], + 'foo' => ['type' => 'bar', 'optional' => false, 'docs' => null], ], ], @@ -52,7 +52,7 @@ public static function getMappingTests(): array [ '{% types {foo: "bar",} %}', [ - 'foo' => ['type' => 'bar', 'optional' => false], + 'foo' => ['type' => 'bar', 'optional' => false, 'docs' => null], ], ], @@ -60,7 +60,7 @@ public static function getMappingTests(): array [ '{% types {foo?: "bar"} %}', [ - 'foo' => ['type' => 'bar', 'optional' => true], + 'foo' => ['type' => 'bar', 'optional' => true, 'docs' => null], ], ], @@ -68,9 +68,9 @@ public static function getMappingTests(): array [ '{% types {foo: "foo", bar?: "foo", baz: "baz"} %}', [ - 'foo' => ['type' => 'foo', 'optional' => false], - 'bar' => ['type' => 'foo', 'optional' => true], - 'baz' => ['type' => 'baz', 'optional' => false], + 'foo' => ['type' => 'foo', 'optional' => false, 'docs' => null], + 'bar' => ['type' => 'foo', 'optional' => true, 'docs' => null], + 'baz' => ['type' => 'baz', 'optional' => false, 'docs' => null], ], ], @@ -78,8 +78,43 @@ public static function getMappingTests(): array [ '{% types foo: "foo", bar: "bar" %}', [ - 'foo' => ['type' => 'foo', 'optional' => false], - 'bar' => ['type' => 'bar', 'optional' => false], + 'foo' => ['type' => 'foo', 'optional' => false, 'docs' => null], + 'bar' => ['type' => 'bar', 'optional' => false, 'docs' => null], + ], + ], + + // with docs attribute + [ + '{% types {foo: "string" docs="The foo description"} %}', + [ + 'foo' => ['type' => 'string', 'optional' => false, 'docs' => 'The foo description'], + ], + ], + + // with docs attribute and optional + [ + '{% types {foo?: "string" docs="The foo description"} %}', + [ + 'foo' => ['type' => 'string', 'optional' => true, 'docs' => 'The foo description'], + ], + ], + + // multiple entries with docs + [ + '{% types {id: "string" docs="Unique identifier", multiple?: "boolean" docs="Allow multiple", value: "mixed"} %}', + [ + 'id' => ['type' => 'string', 'optional' => false, 'docs' => 'Unique identifier'], + 'multiple' => ['type' => 'boolean', 'optional' => true, 'docs' => 'Allow multiple'], + 'value' => ['type' => 'mixed', 'optional' => false, 'docs' => null], + ], + ], + + // without {} enclosing with docs + [ + '{% types foo: "foo" docs="Foo docs", bar: "bar" %}', + [ + 'foo' => ['type' => 'foo', 'optional' => false, 'docs' => 'Foo docs'], + 'bar' => ['type' => 'bar', 'optional' => false, 'docs' => null], ], ], ];