Skip to content
Merged
57 changes: 57 additions & 0 deletions docs/developers/directive.rst
Original file line number Diff line number Diff line change
Expand Up @@ -49,3 +49,60 @@ All you need to do is create a node and add the content.
:caption: your-extension/Directive/ExampleDirective.php
:lineos:

Reading directive options
=========================

A directive can declare the options it accepts using the repeatable
:php:class:`\phpDocumentor\Guides\RestructuredText\Directives\Attributes\Option` attribute on the directive class.
Each option has a ``name``, a ``type`` (one of the
:php:class:`\phpDocumentor\Guides\RestructuredText\Directives\OptionType` enum cases and ``String`` by default) and
an optional ``default`` value that is returned when the option is not present on the directive:

.. code-block:: php
:caption: your-extension/Directive/ExampleDirective.php

use phpDocumentor\Guides\RestructuredText\Directives\Attributes\Option;
use phpDocumentor\Guides\RestructuredText\Directives\OptionType;

#[Option(name: 'title', description: 'The title of the node')]
#[Option(name: 'count', type: OptionType::Integer, default: 3, description: 'How many entries to render')]
#[Option(name: 'enabled', type: OptionType::Boolean, default: true)]
final class ExampleDirective extends SubDirective
{
// ...
}

Inside the directive you fetch a single option with
:php:method:`\phpDocumentor\Guides\RestructuredText\Directives\BaseDirective::readOption()`:

.. code-block:: php

$title = $this->readOption($directive, 'title'); // string|null
$count = $this->readOption($directive, 'count'); // int
$enabled = $this->readOption($directive, 'enabled'); // bool

``readOption()`` returns a value typed according to the matching ``#[Option]`` attribute:

- an ``OptionType::String`` option returns ``string``, ``OptionType::Integer`` returns ``int``,
``OptionType::Boolean`` returns ``bool`` and ``OptionType::Array`` returns ``array``;
- when the option declares a ``default``, the default's type is added to the return type, so an
option without a ``default`` returns ``<type>|null``;
- when no matching ``#[Option]`` attribute can be found for the given name, the return type is ``mixed``.

Static analysis
---------------

The return type of ``readOption()`` is inferred by a PHPStan
``DynamicMethodReturnTypeExtension`` shipped with the ``phpdocumentor/guides-restructured-text`` package. To
enable it, require the package and include its rule set in your ``phpstan.neon``:

.. code-block:: yaml
:caption: phpstan.neon

includes:
- vendor/phpdocumentor/guides-restructured-text/rules.neon

With the rule enabled, PHPStan reports type mismatches when a directive uses an option in a way that
is incompatible with its declared ``#[Option]`` type (for example passing a possibly-``null`` ``string|null``
title to a method that requires a ``string``).

27 changes: 11 additions & 16 deletions docs/developers/directive/subdirective.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,28 +2,23 @@

namespace YourExtension\Directives;

use phpDocumentor\Guides\RestructuredText\Nodes\Node;
use phpDocumentor\Guides\RestructuredText\Nodes\SubDirectiveNode;
use phpDocumentor\Guides\RestructuredText\Parser\SubDirectiveParser;
use phpDocumentor\Guides\RestructuredText\Parser\SubDirectiveParserFactory;
use phpDocumentor\Guides\RestructuredText\Directives\Attributes\Directive;
use phpDocumentor\Guides\RestructuredText\Directives\Attributes\Option;
use phpDocumentor\Guides\RestructuredText\Directives\OptionType;
use phpDocumentor\Guides\RestructuredText\Directives\SubDirective;
use phpDocumentor\Guides\Nodes\Node;

#[Directive(name: 'example')]
#[Option(name: 'option1', type: OptionType::Boolean, description: 'An example option', default: false)]
class ExampleSubDirective extends SubDirective
{
public function getName(): string
public function createNode(\phpDocumentor\Guides\RestructuredText\Nodes\DirectiveNode $directiveNode): Node
{
return 'example';
}

final protected function processSub(
BlockContext $blockContext,
CollectionNode $collectionNode,
Directive $directive,
): Node|null {
return new ExampleNode(
$this->name,
$directive->getDataNode(),
$this->readOption($directiveNode, 'option1'),
$directiveNode->getDataNode(),
$this->text,
$collectionNode->getChildren(),
$directiveNode->getChildren(),
);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
declare(strict_types=1);

use phpDocumentor\Guides\ReferenceResolvers\DocumentNameResolverInterface;
use phpDocumentor\Guides\RestructuredText\Compiler\Passes\DirectiveProcessPass;
use phpDocumentor\Guides\RestructuredText\Directives\AdmonitionDirective;
use phpDocumentor\Guides\RestructuredText\Directives\AttentionDirective;
use phpDocumentor\Guides\RestructuredText\Directives\BaseDirective;
Expand Down Expand Up @@ -280,6 +281,7 @@
->tag('phpdoc.guides.parser.rst.body_element', ['priority' => ParagraphRule::PRIORITY + 1])
->set(DirectiveRule::class)
->arg('$directives', tagged_iterator('phpdoc.guides.directive'))
->arg('$startingRule', service(DirectiveContentRule::class))
->tag('phpdoc.guides.parser.rst.body_element', ['priority' => DirectiveRule::PRIORITY])
->set(CommentRule::class)
->tag('phpdoc.guides.parser.rst.body_element', ['priority' => CommentRule::PRIORITY])
Expand Down Expand Up @@ -381,6 +383,10 @@
->set(GlobSearcher::class)
->set(ToctreeBuilder::class)
->set(InlineMarkupRule::class)

->set(DirectiveProcessPass::class)
->arg('$directives', tagged_iterator('phpdoc.guides.directive'))
->tag('phpdoc.guides.compiler.nodeTransformers')
->set(DefaultCodeNodeOptionMapper::class)
->alias(CodeNodeOptionMapper::class, DefaultCodeNodeOptionMapper::class);
};
5 changes: 5 additions & 0 deletions packages/guides-restructured-text/rules.neon
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
services:
-
class: phpDocumentor\Guides\PHPStan\Rules\ReadOptionReturnTypeExtension
tags:
- phpstan.broker.dynamicMethodReturnTypeExtension
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
<?php

declare(strict_types=1);

/**
* This file is part of phpDocumentor.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*
* @link https://phpdoc.org
*/

namespace phpDocumentor\Guides\PHPStan\Rules;

use phpDocumentor\Guides\RestructuredText\Directives\Attributes\Option;
use phpDocumentor\Guides\RestructuredText\Directives\BaseDirective;
use PhpParser\Node\Expr\MethodCall;
use PHPStan\Analyser\Scope;
use PHPStan\Reflection\ClassReflection;
use PHPStan\Reflection\MethodReflection;
use PHPStan\Type\ArrayType;
use PHPStan\Type\BooleanType;
use PHPStan\Type\DynamicMethodReturnTypeExtension;
use PHPStan\Type\IntegerType;
use PHPStan\Type\MixedType;
use PHPStan\Type\NullType;
use PHPStan\Type\StringType;
use PHPStan\Type\Type;
use PHPStan\Type\TypeCombinator;

use function count;

final class ReadOptionReturnTypeExtension implements DynamicMethodReturnTypeExtension
{
public function getClass(): string
{
return BaseDirective::class;
}

public function isMethodSupported(MethodReflection $methodReflection): bool
{
return $methodReflection->getName() === 'readOption';
}

public function getTypeFromMethodCall(
MethodReflection $methodReflection,
MethodCall $methodCall,
Scope $scope,
): Type|null {
$args = $methodCall->getArgs();
if (count($args) < 2) {
return null;
}

$optionNameType = $scope->getType($args[1]->value);
$constantStrings = $optionNameType->getConstantStrings();
if (count($constantStrings) !== 1) {
return new MixedType();
}

$callerType = $scope->getType($methodCall->var);
$classReflections = $callerType->getObjectClassReflections();
if (count($classReflections) === 0) {
return new MixedType();
}

return $this->resolveReturnType($classReflections[0], $constantStrings[0]->getValue());
}

public function resolveReturnType(ClassReflection $classReflection, string $optionName): Type
{
foreach ($classReflection->getAttributes() as $attribute) {
if ($attribute->getName() !== Option::class) {
continue;
}

$argumentTypes = $attribute->getArgumentTypes();

$nameType = $argumentTypes['name'] ?? null;
if ($nameType === null || count($nameType->getConstantStrings()) !== 1) {
continue;
}

if ($nameType->getConstantStrings()[0]->getValue() !== $optionName) {
continue;
}

return TypeCombinator::union(
$this->resolveBaseType($argumentTypes['type'] ?? null),
$this->resolveDefaultType($argumentTypes['default'] ?? null),
);
}

return new MixedType();
}

private function resolveBaseType(Type|null $typeArgument): Type
{
$enumCase = $typeArgument?->getEnumCaseObject();
if ($enumCase !== null) {
return match ($enumCase->getEnumCaseName()) {
'Integer' => new IntegerType(),
'Boolean' => new BooleanType(),
'Array' => new ArrayType(new MixedType(), new MixedType()),
default => new StringType(),
};
}

return new StringType();
}

private function resolveDefaultType(Type|null $defaultArgument): Type
{
if ($defaultArgument === null) {
return new NullType();
}

if (
$defaultArgument->isNull()->yes()
|| $defaultArgument->isBoolean()->yes()
|| $defaultArgument->isFloat()->yes()
|| $defaultArgument->isInteger()->yes()
|| $defaultArgument->isString()->yes()
) {
return $defaultArgument;
}

return new MixedType();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
<?php

declare(strict_types=1);

/**
* This file is part of phpDocumentor.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*
* @link https://phpdoc.org
*/

namespace phpDocumentor\Guides\RestructuredText\Compiler\Passes;

use phpDocumentor\Guides\Compiler\CompilerContext;
use phpDocumentor\Guides\Compiler\ReverseNodeTransformer;
use phpDocumentor\Guides\Nodes\Node;
use phpDocumentor\Guides\RestructuredText\Directives\BaseDirective as DirectiveHandler;
use phpDocumentor\Guides\RestructuredText\Directives\GeneralDirective;
use phpDocumentor\Guides\RestructuredText\Nodes\DirectiveNode;
use phpDocumentor\Guides\RestructuredText\Parser\Directive;

use function array_merge;
use function strtolower;

/** @implements ReverseNodeTransformer<DirectiveNode> */
final class DirectiveProcessPass implements ReverseNodeTransformer
{
/** @var array<string, DirectiveHandler> */
private array $directives;

/** @param iterable<DirectiveHandler> $directives */
public function __construct(
private readonly GeneralDirective $generalDirective,
iterable $directives = [],
) {
foreach ($directives as $directive) {
$this->registerDirective($directive);
}
}

private function registerDirective(DirectiveHandler $directive): void
{
$this->directives[strtolower($directive->getName())] = $directive;
foreach ($directive->getAliases() as $alias) {
$this->directives[strtolower($alias)] = $directive;
}
}

public function enterNode(Node $node, CompilerContext $compilerContext): Node
{
return $node;
}

public function leaveNode(Node $node, CompilerContext $compilerContext): Node|null
{
if ($node instanceof DirectiveNode === false) {
return $node;
}

$newNode = $this->getDirectiveHandler($node->getDirective())->createNode($node);
if ($newNode === null) {
return null;
}

$newNode->setClasses(array_merge($newNode->getClasses(), $node->getClasses()));

return $newNode;
}

private function getDirectiveHandler(Directive $directive): DirectiveHandler
{
return $this->directives[strtolower($directive->getName())] ?? $this->generalDirective;
}

public function supports(Node $node): bool
{
return $node instanceof DirectiveNode;
}

public function getPriority(): int
{
return 100;
}
}
Loading
Loading