Skip to content
Merged
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
30 changes: 30 additions & 0 deletions src/Analyzer/DocblockParser.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
<?php
declare(strict_types=1);

namespace Arkitect\Analyzer;

use PHPStan\PhpDocParser\Ast\PhpDoc\PhpDocNode;
use PHPStan\PhpDocParser\Lexer\Lexer;
use PHPStan\PhpDocParser\Parser\PhpDocParser;
use PHPStan\PhpDocParser\Parser\TokenIterator;

class DocblockParser
{
private PhpDocParser $innerParser;

private Lexer $innerLexer;

public function __construct(PhpDocParser $innerParser, Lexer $innerLexer)
{
$this->innerParser = $innerParser;
$this->innerLexer = $innerLexer;
}

public function parse(string $docblock): PhpDocNode
{
$tokens = $this->innerLexer->tokenize($docblock);
$tokenIterator = new TokenIterator($tokens);

return $this->innerParser->parse($tokenIterator);
}
}
39 changes: 39 additions & 0 deletions src/Analyzer/DocblockParserFactory.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
<?php
declare(strict_types=1);

namespace Arkitect\Analyzer;

use PHPStan\PhpDocParser\Lexer\Lexer;
use PHPStan\PhpDocParser\Parser\ConstExprParser;
use PHPStan\PhpDocParser\Parser\PhpDocParser;
use PHPStan\PhpDocParser\Parser\TypeParser;
use PHPStan\PhpDocParser\ParserConfig;

class DocblockParserFactory
{
/**
* @psalm-suppress TooFewArguments
* @psalm-suppress InvalidArgument
*/
public static function create(): DocblockParser
{
$phpDocParser = null;
$phpDocLexer = null;

// this if is to allow using v 1.2 or v2
if (class_exists(ParserConfig::class)) {
$parserConfig = new ParserConfig([]);
$constExprParser = new ConstExprParser($parserConfig);
$typeParser = new TypeParser($parserConfig, $constExprParser);
$phpDocParser = new PhpDocParser($parserConfig, $typeParser, $constExprParser);
$phpDocLexer = new Lexer($parserConfig);
} else {
$typeParser = new TypeParser();
$constExprParser = new ConstExprParser();
$phpDocParser = new PhpDocParser($typeParser, $constExprParser);
$phpDocLexer = new Lexer();

Check warning on line 34 in src/Analyzer/DocblockParserFactory.php

View check run for this annotation

Codecov / codecov/patch

src/Analyzer/DocblockParserFactory.php#L31-L34

Added lines #L31 - L34 were not covered by tests
}

return new DocblockParser($phpDocParser, $phpDocLexer);
}
}
268 changes: 268 additions & 0 deletions src/Analyzer/DocblockTypesResolver.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,268 @@
<?php

declare(strict_types=1);

namespace Arkitect\Analyzer;

use PhpParser\Comment\Doc;
use PhpParser\ErrorHandler;
use PhpParser\NameContext;
use PhpParser\Node;
use PhpParser\Node\Expr;
use PhpParser\Node\Name;
use PhpParser\Node\Name\FullyQualified;
use PhpParser\Node\Stmt;
use PhpParser\NodeAbstract;
use PhpParser\NodeVisitorAbstract;
use PHPStan\PhpDocParser\Ast\PhpDoc\PhpDocNode;
use PHPStan\PhpDocParser\Ast\Type\ArrayTypeNode;
use PHPStan\PhpDocParser\Ast\Type\GenericTypeNode;
use PHPStan\PhpDocParser\Ast\Type\TypeNode;

/**
* This class is used to collect type information from dockblocks, in particular
* - regular dockblock tags: @param, @var, @return
* - old style annotations like @Assert\Blank
* and assign them to the piece of code the docblock is attached to.
*
* This allows to detect dependencies declared only in dockblocks
*/
class DocblockTypesResolver extends NodeVisitorAbstract
{
private NameContext $nameContext;

private bool $parseCustomAnnotations;

private DocblockParser $docblockParser;

public function __construct(bool $parseCustomAnnotations = true)
{
$this->nameContext = new NameContext(new ErrorHandler\Throwing());

$this->parseCustomAnnotations = $parseCustomAnnotations;

$this->docblockParser = DocblockParserFactory::create();
}

public function beforeTraverse(array $nodes): ?array
{
// this also clears the name context so there is not need to reinstantiate it
$this->nameContext->startNamespace();

return null;
}

public function enterNode(Node $node): void
{
if ($node instanceof Stmt\Namespace_) {
$this->nameContext->startNamespace($node->name);
}

if ($node instanceof Stmt\Use_) {
$this->addAliases($node->uses, $node->type, null);
}

if ($node instanceof Stmt\GroupUse) {
$this->addAliases($node->uses, $node->type, $node->prefix);
}

$this->resolveFunctionTypes($node);

$this->resolveParamTypes($node);
}

private function resolveParamTypes(Node $node): void
{
if (!($node instanceof Stmt\Property)) {
return;
}

$phpDocNode = $this->parseDocblock($node);

if (null === $phpDocNode) {
return;
}

if ($this->isNodeOfTypeArray($node)) {
$arrayItemType = null;

foreach ($phpDocNode->getVarTagValues() as $tagValue) {
$arrayItemType = $this->getArrayItemType($tagValue->type);
}

if (null !== $arrayItemType) {
$node->type = $this->resolveName(new Name($arrayItemType), Stmt\Use_::TYPE_NORMAL);

return;
}
}

foreach ($phpDocNode->getVarTagValues() as $tagValue) {
$type = $this->resolveName(new Name((string) $tagValue->type), Stmt\Use_::TYPE_NORMAL);
$node->type = $type;
break;
}

if ($this->parseCustomAnnotations && !($node->type instanceof FullyQualified)) {
foreach ($phpDocNode->getTags() as $tagValue) {
if ('@' === $tagValue->name[0] && !str_contains($tagValue->name, '@var')) {
$customTag = str_replace('@', '', $tagValue->name);
$type = $this->resolveName(new Name($customTag), Stmt\Use_::TYPE_NORMAL);
$node->type = $type;

break;
}
}
}
}

private function resolveFunctionTypes(Node $node): void
{
if (
!($node instanceof Stmt\ClassMethod
|| $node instanceof Stmt\Function_
|| $node instanceof Expr\Closure
|| $node instanceof Expr\ArrowFunction)
) {
return;
}

$phpDocNode = $this->parseDocblock($node);

if (null === $phpDocNode) { // no docblock, nothing to do
return;
}

foreach ($node->params as $param) {
if (!$this->isNodeOfTypeArray($param)) { // not an array, nothing to do
continue;
}

foreach ($phpDocNode->getParamTagValues() as $phpDocParam) {
if ($param->var instanceof Expr\Variable && \is_string($param->var->name) && $phpDocParam->parameterName === ('$'.$param->var->name)) {
$arrayItemType = $this->getArrayItemType($phpDocParam->type);

if (null !== $arrayItemType) {
$param->type = $this->resolveName(new Name($arrayItemType), Stmt\Use_::TYPE_NORMAL);
}
}
}
}

if ($node->returnType instanceof Node\Identifier && 'array' === $node->returnType->name) {
$arrayItemType = null;

foreach ($phpDocNode->getReturnTagValues() as $tagValue) {
$arrayItemType = $this->getArrayItemType($tagValue->type);
}

if (null !== $arrayItemType) {
$node->returnType = $this->resolveName(new Name($arrayItemType), Stmt\Use_::TYPE_NORMAL);
}
}
}

/**
* Resolve name, according to name resolver options.
*
* @param Name $name Function or constant name to resolve
* @param Stmt\Use_::TYPE_* $type One of Stmt\Use_::TYPE_*
*
* @return Name Resolved name, or original name with attribute
*/
private function resolveName(Name $name, int $type): Name
{
$resolvedName = $this->nameContext->getResolvedName($name, $type);

if (null !== $resolvedName) {
return $resolvedName;
}

// unqualified names inside a namespace cannot be resolved at compile-time
// add the namespaced version of the name as an attribute
$name->setAttribute('namespacedName', FullyQualified::concat(
$this->nameContext->getNamespace(),
$name,
$name->getAttributes()
));

Check warning on line 187 in src/Analyzer/DocblockTypesResolver.php

View check run for this annotation

Codecov / codecov/patch

src/Analyzer/DocblockTypesResolver.php#L183-L187

Added lines #L183 - L187 were not covered by tests

return $name;

Check warning on line 189 in src/Analyzer/DocblockTypesResolver.php

View check run for this annotation

Codecov / codecov/patch

src/Analyzer/DocblockTypesResolver.php#L189

Added line #L189 was not covered by tests
}

/**
* @param array<Node\UseItem> $uses
*/
private function addAliases(array $uses, int $type, ?Name $prefix = null): void
{
foreach ($uses as $useItem) {
$this->addAlias($useItem, $type, $prefix);
}
}

/**
* @psalm-suppress PossiblyNullArgument
* @psalm-suppress ArgumentTypeCoercion
*/
private function addAlias(Node\UseItem $use, int $type, ?Name $prefix = null): void
{
// Add prefix for group uses
$name = $prefix ? Name::concat($prefix, $use->name) : $use->name;
// Type is determined either by individual element or whole use declaration
$type |= $use->type;

$this->nameContext->addAlias(
$name,
(string) $use->getAlias(),
$type,
$use->getAttributes()
);
}

private function parseDocblock(NodeAbstract $node): ?PhpDocNode
{
if (null === $node->getDocComment()) {
return null;
}

/** @var Doc $docComment */
$docComment = $node->getDocComment();

return $this->docblockParser->parse($docComment->getText());
}

/**
* @param Node\Param|Stmt\Property $node
*/
private function isNodeOfTypeArray($node): bool
{
return null !== $node->type && isset($node->type->name) && 'array' === $node->type->name;
}

private function getArrayItemType(TypeNode $typeNode): ?string
{
$arrayItemType = null;

if ($typeNode instanceof GenericTypeNode) {
if (1 === \count($typeNode->genericTypes)) {
// this handles list<ClassName>
$arrayItemType = (string) $typeNode->genericTypes[0];
} elseif (2 === \count($typeNode->genericTypes)) {
// this handles array<int, ClassName>
$arrayItemType = (string) $typeNode->genericTypes[1];
}
}

if ($typeNode instanceof ArrayTypeNode) {
// this handles ClassName[]
$arrayItemType = (string) $typeNode->type;
}

$validFqcn = '/^[a-zA-Z_\x7f-\xff\\\\][a-zA-Z0-9_\x7f-\xff\\\\]*[a-zA-Z0-9_\x7f-\xff]$/';

if (null !== $arrayItemType && !(bool) preg_match($validFqcn, $arrayItemType)) {
return null;

Check warning on line 263 in src/Analyzer/DocblockTypesResolver.php

View check run for this annotation

Codecov / codecov/patch

src/Analyzer/DocblockTypesResolver.php#L263

Added line #L263 was not covered by tests
}

return $arrayItemType;
}
}
3 changes: 3 additions & 0 deletions src/Analyzer/FileParser.php
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
use Arkitect\Rules\ParsingError;
use PhpParser\ErrorHandler\Collecting;
use PhpParser\NodeTraverser;
use PhpParser\NodeVisitor\NameResolver;
use PhpParser\Parser as PhpParser;
use PhpParser\ParserFactory;
use PhpParser\PhpVersion;
Expand All @@ -27,6 +28,7 @@ public function __construct(
NodeTraverser $traverser,
FileVisitor $fileVisitor,
NameResolver $nameResolver,
DocblockTypesResolver $docblockTypesResolver,
TargetPhpVersion $targetPhpVersion
) {
$this->fileVisitor = $fileVisitor;
Expand All @@ -35,6 +37,7 @@ public function __construct(
$this->parser = (new ParserFactory())->createForVersion(PhpVersion::fromString($targetPhpVersion->get()));
$this->traverser = $traverser;
$this->traverser->addVisitor($nameResolver);
$this->traverser->addVisitor($docblockTypesResolver);
$this->traverser->addVisitor($this->fileVisitor);
}

Expand Down
4 changes: 3 additions & 1 deletion src/Analyzer/FileParserFactory.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@

use Arkitect\CLI\TargetPhpVersion;
use PhpParser\NodeTraverser;
use PhpParser\NodeVisitor\NameResolver;

class FileParserFactory
{
Expand All @@ -14,7 +15,8 @@ public static function createFileParser(TargetPhpVersion $targetPhpVersion, bool
return new FileParser(
new NodeTraverser(),
new FileVisitor(new ClassDescriptionBuilder()),
new NameResolver(null, ['parseCustomAnnotations' => $parseCustomAnnotations]),
new NameResolver(),
new DocblockTypesResolver($parseCustomAnnotations),
$targetPhpVersion
);
}
Expand Down
Loading