Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion CHANGELOG
Original file line number Diff line number Diff line change
@@ -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)

Expand Down
1 change: 1 addition & 0 deletions src/Node/BlockNode.php
Original file line number Diff line number Diff line change
Expand Up @@ -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<null|scalar|\Stringable>\n")
->write(" */\n")
->write(\sprintf("public function block_%s(array \$context, array \$blocks = []): iterable\n", $this->getAttribute('name')), "{\n")
Expand Down
2 changes: 1 addition & 1 deletion src/Node/TypesNode.php
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@
class TypesNode extends Node
{
/**
* @param array<string, array{type: string, optional: bool}> $types
* @param array<string, array{type: string, optional: bool, docs: ?string}> $types
*/
public function __construct(array $types, int $lineno)
{
Expand Down
11 changes: 11 additions & 0 deletions src/TokenParser/BlockTokenParser.php
Original file line number Diff line number Diff line change
Expand Up @@ -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)) {
Expand All @@ -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();

Expand Down
11 changes: 10 additions & 1 deletion src/TokenParser/TypesTokenParser.php
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ public function parse(Token $token): Node
}

/**
* @return array<string, array{type: string, optional: bool}>
* @return array<string, array{type: string, optional: bool, docs: ?string}>
*
* @throws SyntaxError
*/
Expand Down Expand Up @@ -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,
];
}

Expand Down
39 changes: 39 additions & 0 deletions tests/Node/BlockTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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), <<<EOF
// line 1
/**
Expand All @@ -54,6 +56,43 @@ public function block_foo(array \$context, array \$blocks = []): iterable
EOF, new Environment(new ArrayLoader()),
];

// block with docs
$blockWithDocs = new BlockNode('foo', new TextNode('foo', 1), 1);
$blockWithDocs->setAttribute('docs', 'The foo block description');
$tests[] = [$blockWithDocs, <<<EOF
// line 1
/**
* The foo block description
* @return iterable<null|scalar|\Stringable>
*/
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, <<<EOF
// line 1
/**
* First line
* Second line
* @return iterable<null|scalar|\Stringable>
*/
public function block_foo(array \$context, array \$blocks = []): iterable
{
\$macros = \$this->macros;
yield "foo";
yield from [];
}
EOF, new Environment(new ArrayLoader()),
];

return $tests;
}
}
2 changes: 2 additions & 0 deletions tests/Node/TypesTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -23,10 +23,12 @@ private static function getValidMapping(): array
'foo' => [
'type' => 'string',
'optional' => false,
'docs' => null,
],
'bar' => [
'type' => 'number',
'optional' => true,
'docs' => null,
],
];
}
Expand Down
70 changes: 70 additions & 0 deletions tests/TokenParser/BlockTokenParserTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
<?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\Tests\TokenParser;

use PHPUnit\Framework\TestCase;
use Twig\Environment;
use Twig\Loader\ArrayLoader;
use Twig\Parser;
use Twig\Source;

class BlockTokenParserTest extends TestCase
{
/** @dataProvider getBlockTests */
public function testBlockParsing(string $template, string $blockName, ?string $expectedDocs)
{
$env = new Environment(new ArrayLoader(), ['cache' => 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',
],
];
}
}
53 changes: 44 additions & 9 deletions tests/TokenParser/TypesTokenParserTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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, ''));
Expand All @@ -44,42 +44,77 @@ public static function getMappingTests(): array
[
'{% types {foo: "bar"} %}',
[
'foo' => ['type' => 'bar', 'optional' => false],
'foo' => ['type' => 'bar', 'optional' => false, 'docs' => null],
],
],

// trailing comma
[
'{% types {foo: "bar",} %}',
[
'foo' => ['type' => 'bar', 'optional' => false],
'foo' => ['type' => 'bar', 'optional' => false, 'docs' => null],
],
],

// optional name
[
'{% types {foo?: "bar"} %}',
[
'foo' => ['type' => 'bar', 'optional' => true],
'foo' => ['type' => 'bar', 'optional' => true, 'docs' => null],
],
],

// 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],
'foo' => ['type' => 'foo', 'optional' => false, 'docs' => null],
'bar' => ['type' => 'foo', 'optional' => true, 'docs' => null],
'baz' => ['type' => 'baz', 'optional' => false, 'docs' => null],
],
],

// without {} enclosing
[
'{% 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],
],
],
];
Expand Down
Loading